feat(servers): add form-based new-server creation with toolbar entry

Servers view toolbar gains an icon-only new-server button opening a
sheet that mirrors the edit form (port 8388 / aes-256-gcm defaults,
managed-plugin section). New CatalogWorkflow.createServer command keeps
validation symmetric with edit commit: form validation fails bare
before the credential journal; password and plugin options commit with
the catalog as one logical change and roll back on persistence
failure. Placement reuses importTargetParent semantics; success
dismisses and selects the new server. Fresh UUID manual source, no
content dedup, activation target untouched.
This commit is contained in:
Qiu Yuzhou
2026-09-28 22:03:41 +08:00
parent a3ff3c4f92
commit e0f9016116
6 changed files with 398 additions and 39 deletions
@@ -27,7 +27,48 @@ extension CatalogWorkflow {
).encode()
}
// MARK: - URI → 服务器叶子
// MARK: - URI 批量导入与叶子字段
/// ss:// 批量导入(URL 表单和二维码识别入口的共同落点,story 20/21)。
/// 逐行解码,可解析行全部添加(每次新建身份,不按内容去重);每条失败行以
/// 行号 + 类型化原因点名,已成功记录不被局部失败回滚。
func createServers(fromURIs text: String, into parent: NodeID?) async throws
-> BatchImportOutcome
{
var prepared: [(uri: SsUri, fields: ServerFields)] = []
var failures: [ImportLineFailure] = []
for (index, line) in text.split(whereSeparator: \.isNewline).enumerated() {
do {
let uri = try SsUri.decode(String(line))
let fields = try Self.serverFields(from: uri, credentials: dependencies.credentials)
prepared.append((uri: uri, fields: fields))
} catch {
let reason: ImportLineFailureReason
if let uriError = error as? SsUriError {
reason = .decode(uriError)
} else if let credentialError = error as? CredentialStoreError {
reason = .credential(credentialError)
} else {
reason = .decode(.malformed(detail: String(describing: error)))
}
failures.append(ImportLineFailure(lineIndex: index, reason: reason))
}
}
guard !prepared.isEmpty else { return BatchImportOutcome(addedCount: 0, failures: failures) }
do {
try commit { catalog in
for item in prepared {
try catalog.addServer(item.fields, to: parent)
}
}
} catch {
for item in prepared {
Self.deleteCredentialRefs(for: item.fields, credentials: dependencies.credentials)
}
throw error
}
return BatchImportOutcome(addedCount: prepared.count, failures: failures)
}
/// URI → 服务器叶子字段:密码与插件参数入凭据存储、目录只持引用。
static func serverFields(
@@ -130,45 +130,44 @@ final class CatalogWorkflow: ObservableObject {
// MARK: - 手动服务器/分组命令
/// ss:// 批量导入(URL 表单和二维码识别入口的共同落点,story 20/21)。
/// 逐行解码,可解析行全部添加(每次新建身份,不按内容去重);每条失败行以
/// 行号 + 类型化原因点名,已成功记录不被局部失败回滚。
func createServers(fromURIs text: String, into parent: NodeID?) async throws
-> BatchImportOutcome
{
var prepared: [(uri: SsUri, fields: ServerFields)] = []
var failures: [ImportLineFailure] = []
for (index, line) in text.split(whereSeparator: \.isNewline).enumerated() {
do {
let uri = try SsUri.decode(String(line))
let fields = try Self.serverFields(from: uri, credentials: dependencies.credentials)
prepared.append((uri: uri, fields: fields))
} catch {
let reason: ImportLineFailureReason
if let uriError = error as? SsUriError {
reason = .decode(uriError)
} else if let credentialError = error as? CredentialStoreError {
reason = .credential(credentialError)
} else {
reason = .decode(.malformed(detail: String(describing: error)))
}
failures.append(ImportLineFailure(lineIndex: index, reason: reason))
}
/// 表单新建手动服务器:校验与提交语义与编辑对称——表单校验在建 journal
/// 前失败保持裸 `ServerFormError`;密码与可选插件参数和目录作为一个逻辑
/// 变更提交,持久化失败经 journal 回滚并以 `CommitError` 报出。新身份恒
/// 全新 UUID,不按内容去重(CONTEXT.md 不变量);返回新节点身份供选中。
@discardableResult
func createServer(_ draft: ServerEditDraft, into parent: NodeID?) async throws -> NodeID {
let trimmedAddress = draft.address.trimmingCharacters(in: .whitespaces)
guard !trimmedAddress.isEmpty else { throw ServerFormError.invalidAddress }
guard (1...65_535).contains(draft.port) else { throw ServerFormError.invalidPort }
let trimmedMethod = draft.encryptionMethod.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedMethod.isEmpty else { throw ServerFormError.missingEncryptionMethod }
guard EncryptionMethodCatalog.isSupported(trimmedMethod) else {
throw ServerFormError.unsupportedEncryptionMethod(trimmedMethod)
}
guard !prepared.isEmpty else { return BatchImportOutcome(addedCount: 0, failures: failures) }
guard !draft.password.isEmpty else { throw ServerFormError.invalidPassword }
// 插件表单校验与其它字段同处写入之前:表单拒绝不触碰凭据、不进提交管线。
if case .managed(let program) = draft.plugin,
ManagedPluginCatalog.info(forProgram: program) == nil
{
throw ServerFormError.pluginNotManaged(program)
}
var journal = CredentialWriteJournal(credentials: dependencies.credentials)
do {
try commit { catalog in
for item in prepared {
try catalog.addServer(item.fields, to: parent)
}
return try commit { [self] catalog in
let passwordRef = CredentialReference.fresh()
try journal.save(draft.password, for: passwordRef)
var fields = ServerFields(
address: trimmedAddress, port: draft.port, encryptionMethod: trimmedMethod,
passwordRef: passwordRef,
remark: draft.remark.trimmingCharacters(in: .whitespaces))
try Self.applyPluginSelection(
draft.plugin, options: draft.pluginOptions, to: &fields,
credentials: dependencies.credentials, journal: &journal)
return try catalog.addServer(fields, to: parent)
}
} catch {
for item in prepared {
Self.deleteCredentialRefs(for: item.fields, credentials: dependencies.credentials)
}
throw error
throw CommitError(underlying: error, credentialRollback: journal.rollback())
}
return BatchImportOutcome(addedCount: prepared.count, failures: failures)
}
/// 新建空手动分组(story 5);返回新分组身份。
@@ -253,8 +252,9 @@ final class CatalogWorkflow: ObservableObject {
// MARK: - 提交管线(module 内部;UI 不得调用,独立 target 拆分前靠 deletion check)
/// 仅目录变更的提交便捷入口(订阅扩展经 `commitSubscriptionDocument`)。
private func commit<T>(
/// 仅目录变更的提交便捷入口(module 内部扩展共用,同 `commitSubscriptionDocument`
/// 的窄缝口径)。
func commit<T>(
_ mutate: (inout ConfigurationCatalog) throws -> T
) throws -> T {
try commitDocument { catalog, _ in
+146
View File
@@ -0,0 +1,146 @@
import SwiftUI
/// 「新建服务器」表单(服务器视图工具栏入口):字段、校验与插件区镜像编辑
/// 表单,默认值端口 8388、加密 aes-256-gcm、插件「无」。落点由父视图按既有
/// `importTargetParent` 语义在打开时固定;提交成功关闭表单并把新身份交给
/// selection(由父视图选中);校验/提交失败留在表单,错误经共享呈现器点名。
struct NewServerSheet: View {
let workflow: CatalogWorkflow
let errors: ErrorAlertPresenter
let parent: NodeID?
@Binding var selection: NodeID?
@Environment(\.dismiss) private var dismiss
@State private var address = ""
@State private var port = 8388
@State private var encryptionMethod = "aes-256-gcm"
@State private var password = ""
@State private var remark = ""
@State private var pluginChoice: PluginSelection = .none
@State private var pluginOptionsText = ""
@State private var showPassword = false
var body: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Text("新建服务器")
.font(.title2)
Text("保存后加入目录;密码存入钥匙串,目录只持引用。")
.font(.caption)
.foregroundStyle(.secondary)
}
Grid(alignment: .leading, horizontalSpacing: 20, verticalSpacing: 18) {
GridRow {
column("服务器地址") {
TextField("服务器地址", text: $address)
.textFieldStyle(.roundedBorder)
}
column("端口") {
TextField("端口", value: $port, format: .number.grouping(.never))
.textFieldStyle(.roundedBorder)
}
}
GridRow {
column("加密方式") {
Picker("加密方式", selection: $encryptionMethod) {
ForEach(EncryptionMethodCatalog.supported.sorted(), id: \.self) {
Text($0).tag($0)
}
}
}
column("备注") {
TextField("备注", text: $remark)
.textFieldStyle(.roundedBorder)
}
}
GridRow {
column("密码") {
HStack(spacing: 8) {
Group {
if showPassword {
TextField("密码", text: $password)
} else {
SecureField("密码", text: $password)
}
}
.textFieldStyle(.roundedBorder)
Button {
showPassword.toggle()
} label: {
Image(systemName: showPassword ? "eye.slash" : "eye")
}
.buttonStyle(.borderless)
.help(showPassword ? "隐藏密码" : "显示密码")
}
}
.gridCellColumns(2)
}
GridRow {
column("受管理插件与参数") {
ServerPluginSection(
selection: $pluginChoice,
optionsText: $pluginOptionsText,
plugin: pluginSection,
isEditable: true)
}
.gridCellColumns(2)
}
}
HStack {
Spacer()
Button("取消", role: .cancel) { dismiss() }
Button("创建") { create() }
.keyboardShortcut(.defaultAction)
}
}
.padding(20)
.frame(minWidth: 520, minHeight: 400)
}
/// 插件区状态:无既有引用,参数按未配置呈现。新表单只能产生「无」或受管
/// 选择;`provided` 恒为真——新建尚无引用可点名,可执行文件缺失由激活
/// 语义拒绝并在编辑面呈现。
private var pluginSection: PluginSectionState {
PluginSectionState(
selection: pluginChoice,
managed: ManagedPluginCatalog.plugins,
provided: true,
optionsPresent: false,
options: "")
}
private func create() {
Task {
do {
let id = try await workflow.createServer(
ServerEditDraft(
address: address,
port: port,
encryptionMethod: encryptionMethod,
password: password,
remark: remark,
plugin: pluginChoice,
pluginOptions: pluginOptionsText),
into: parent)
selection = id
dismiss()
} catch {
errors.present(error)
}
}
}
private func column<Content: View>(
_ label: String, @ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(.callout.weight(.medium))
.foregroundStyle(.secondary)
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
+18 -2
View File
@@ -22,6 +22,7 @@ struct ServersView: View {
@State private var newGroupParent: NodeID?
@State private var newGroupName = ""
@State private var isPresentingNewGroup = false
@State private var isPresentingNewServer = false
@State private var deleteTarget: NodeID?
@State private var moveTarget: NodeID?
@State private var rootDropHovering = false
@@ -84,6 +85,12 @@ struct ServersView: View {
) { context in
MoveNodeSheet(workflow: workflow, errors: errors, nodeID: context.nodeID)
}
.sheet(isPresented: $isPresentingNewServer) {
NewServerSheet(
workflow: workflow, errors: errors,
parent: workflow.importTargetParent(for: selection),
selection: $selection)
}
}
private var serverSidebar: some View {
@@ -121,7 +128,7 @@ struct ServersView: View {
if workflow.tree.isEmpty {
ContentUnavailableView(
"暂无服务器", systemImage: "server.rack",
description: Text("用工具栏的「添加」菜单导入 ss:// 链接,或用「新建分组」按钮创建分组")
description: Text("用工具栏的「新建服务器」按钮手动录入,或用「添加」菜单导入 ss:// 链接、创建分组")
)
.allowsHitTesting(false)
}
@@ -181,10 +188,19 @@ extension ServersView {
}
}
// MARK: - 工具栏(新建分组)
// MARK: - 工具栏(新建服务器 / 新建分组)
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .primaryAction) {
Button {
isPresentingNewServer = true
} label: {
Label("新建服务器", systemImage: "plus")
}
.labelStyle(.iconOnly)
.help("新建服务器")
}
ToolbarItem(placement: .primaryAction) {
Button {
presentNewGroup(in: workflow.importTargetParent(for: selection))
@@ -0,0 +1,127 @@
import XCTest
@testable import ShadowsocksX_NG2
/// 表单新建手动服务器的点名校验用例(标签 + 草稿 + 预期 typed error)。
private struct InvalidCreateDraftCase {
let label: String
let draft: ServerEditDraft
let expected: ServerFormError
init(_ label: String, _ draft: ServerEditDraft, _ expected: ServerFormError) {
self.label = label
self.draft = draft
self.expected = expected
}
}
/// 表单新建手动服务器命令(`createServer`)的 UI-facing interface 测试:
/// 落点与身份、凭据入存储、点名校验拒绝与插件选择落盘,语义与编辑提交对称。
@MainActor
final class CatalogWorkflowCreateServerTests: XCTestCase {
private var workDir: URL!
private var fileURL: URL!
private var credentials: InMemoryCredentialStore!
private var runtime: FakeCatalogRuntime!
var workflow: CatalogWorkflow!
override func setUp() async throws {
try await super.setUp()
// 专用工作目录:写入器会把父目录强制 0700(不得指向临时根)。
workDir = FileManager.default.temporaryDirectory
.appendingPathComponent("catalog-create-server-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true)
fileURL = workDir.appendingPathComponent("catalog.json")
credentials = InMemoryCredentialStore()
runtime = FakeCatalogRuntime()
runtime.hasActiveTarget = true
workflow = makeWorkflow()
}
override func tearDown() async throws {
try? FileManager.default.removeItem(at: workDir)
try await super.tearDown()
}
private func makeWorkflow() -> CatalogWorkflow {
let coordinator = CatalogCommitCoordinator(
fileStore: CatalogFileStore(fileURL: fileURL), runtime: runtime)
return makeCatalogWorkflow(
coordinator: coordinator,
credentials: credentials,
plugins: NoManagedPluginProvider())
}
func testCreateServerAddsManualLeafWithCredentialAndReturnsIdentity() async throws {
let groupID = try await workflow.createGroup(named: "手动组", into: nil)
let id = try await workflow.createServer(
ServerEditDraft(
address: "203.0.113.10", port: 8389, encryptionMethod: "aes-256-gcm",
password: "表单密码", remark: "新来的", plugin: .none, pluginOptions: nil),
into: groupID)
let node = try XCTUnwrap(workflow.tree.node(withID: id))
XCTAssertFalse(node.isGroup)
XCTAssertTrue(node.isManual)
XCTAssertEqual(node.parentID, groupID)
let form = try XCTUnwrap(workflow.serverEditForm(for: id))
XCTAssertEqual(form.address, "203.0.113.10")
XCTAssertEqual(form.port, 8389)
XCTAssertEqual(form.remark, "新来的")
XCTAssertEqual(form.encryptionMethod, "aes-256-gcm")
XCTAssertEqual(form.password, "表单密码", "密码作为凭据写入,目录持引用")
XCTAssertTrue(node.invalidReasons.isEmpty)
}
func testCreateServerRejectsInvalidDraftsLeavingCatalogAndCredentialsUntouched() async throws {
let groupID = try await workflow.createGroup(named: "组", into: nil)
for caseItem in Self.invalidDraftCases {
await expectThrowsAsync(
{ _ = try await workflow.createServer(caseItem.draft, into: groupID) },
onThrow: { error in
XCTAssertEqual(error as? ServerFormError, caseItem.expected, caseItem.label)
})
}
XCTAssertEqual(workflow.tree.node(withID: groupID)?.childCount, 0, "校验失败不进目录")
XCTAssertTrue(credentials.storageSnapshot.isEmpty, "表单校验在建 journal 前失败,不触碰凭据")
}
func testCreateServerPluginSelectionStoresProgramAndOptions() async throws {
let id = try await workflow.createServer(
ServerEditDraft(
address: "203.0.113.10", port: 8388, encryptionMethod: "aes-256-gcm",
password: "密码", remark: "", plugin: .managed(program: "v2ray-plugin"),
pluginOptions: "mode=websocket;host=example.com"),
into: nil)
let plugin = try XCTUnwrap(workflow.serverEditForm(for: id)?.plugin)
XCTAssertEqual(plugin.selection, .managed(program: "v2ray-plugin"))
XCTAssertTrue(plugin.optionsPresent)
XCTAssertEqual(plugin.options, "mode=websocket;host=example.com")
}
/// 表单校验用例表:与 `updateServer` 同规则的点名拒绝(含受管集外插件)。
private static var invalidDraftCases: [InvalidCreateDraftCase] {
func draft(
address: String = "203.0.113.10", port: Int = 8388,
encryptionMethod: String = "aes-256-gcm", password: String = "密码",
plugin: PluginSelection = .none
) -> ServerEditDraft {
ServerEditDraft(
address: address, port: port, encryptionMethod: encryptionMethod, password: password,
remark: "", plugin: plugin, pluginOptions: nil)
}
return [
InvalidCreateDraftCase("空地址", draft(address: " "), .invalidAddress),
InvalidCreateDraftCase("端口下界", draft(port: 0), .invalidPort),
InvalidCreateDraftCase("端口上界", draft(port: 65_536), .invalidPort),
InvalidCreateDraftCase("缺加密方式", draft(encryptionMethod: " "), .missingEncryptionMethod),
InvalidCreateDraftCase(
"不支持加密方式", draft(encryptionMethod: "future-cipher"),
.unsupportedEncryptionMethod("future-cipher")),
InvalidCreateDraftCase("空密码", draft(password: ""), .invalidPassword),
InvalidCreateDraftCase(
"受管集外插件", draft(plugin: .managed(program: "future-plugin")),
.pluginNotManaged("future-plugin")),
]
}
}
@@ -171,6 +171,35 @@ final class CatalogWorkflowRollbackTests: XCTestCase {
}
}
// MARK: - story 13:表单新建持久化失败 → 新写凭据回滚为不存在
func testCreateServerRollsBackFreshCredentialWhenPersistenceFails() async throws {
let workflow = makeWorkflow()
// 先落一份空目录建立 gate 存储(写入器才会创建父目录),再破坏持久化。
try CatalogFileStore(fileURL: fileURL).save(CatalogDocument(catalog: ConfigurationCatalog()))
try breakPersistence()
await expectThrowsAsync {
try await workflow.createServer(
ServerEditDraft(
address: "198.51.100.9", port: 8388, encryptionMethod: "aes-256-gcm",
password: "新密码", remark: "", plugin: .none, pluginOptions: nil),
into: nil)
} onThrow: { error in
guard let commitError = error as? CommitError else {
XCTFail("预期 CommitError,收到 \(error)")
return
}
guard case .restored(let refs) = commitError.credentialRollback, refs.count == 1 else {
return XCTFail("预期恰有一个全新引用回滚为删除,实际 \(commitError.credentialRollback)")
}
}
XCTAssertTrue(workflow.tree.isEmpty, "目录提交失败则已发布状态不动")
XCTAssertTrue(credentials.storageSnapshot.isEmpty, "新写秘密已删除,不留孤儿")
}
// MARK: - story 38:提交完成与运行时收敛是分离的观察面
func testCommitCompletesWhileRuntimeConvergenceIsStillSyncing() async throws {