From e0f9016116a7fa67b659c1ffa06e3bcd248d424b Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Mon, 28 Sep 2026 22:02:38 +0800 Subject: [PATCH] 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. --- .../CatalogWorkflow/CatalogWorkflow+URI.swift | 43 +++++- .../App/CatalogWorkflow/CatalogWorkflow.swift | 72 ++++----- ShadowsocksX-NG2/App/NewServerSheet.swift | 146 ++++++++++++++++++ ShadowsocksX-NG2/App/ServersView.swift | 20 ++- .../CatalogWorkflowCreateServerTests.swift | 127 +++++++++++++++ .../Tests/CatalogWorkflowRollbackTests.swift | 29 ++++ 6 files changed, 398 insertions(+), 39 deletions(-) create mode 100644 ShadowsocksX-NG2/App/NewServerSheet.swift create mode 100644 ShadowsocksX-NG2/Tests/CatalogWorkflowCreateServerTests.swift diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+URI.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+URI.swift index 8272f941..0d7066de 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+URI.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+URI.swift @@ -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( diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift index 53bed0d7..8894e465 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow.swift @@ -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( + /// 仅目录变更的提交便捷入口(module 内部扩展共用,同 `commitSubscriptionDocument` + /// 的窄缝口径)。 + func commit( _ mutate: (inout ConfigurationCatalog) throws -> T ) throws -> T { try commitDocument { catalog, _ in diff --git a/ShadowsocksX-NG2/App/NewServerSheet.swift b/ShadowsocksX-NG2/App/NewServerSheet.swift new file mode 100644 index 00000000..bddba5bb --- /dev/null +++ b/ShadowsocksX-NG2/App/NewServerSheet.swift @@ -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( + _ 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) + } +} diff --git a/ShadowsocksX-NG2/App/ServersView.swift b/ShadowsocksX-NG2/App/ServersView.swift index e90f5ff7..7ed22d52 100644 --- a/ShadowsocksX-NG2/App/ServersView.swift +++ b/ShadowsocksX-NG2/App/ServersView.swift @@ -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)) diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowCreateServerTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowCreateServerTests.swift new file mode 100644 index 00000000..f9fd3531 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowCreateServerTests.swift @@ -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")), + ] + } +} diff --git a/ShadowsocksX-NG2/Tests/CatalogWorkflowRollbackTests.swift b/ShadowsocksX-NG2/Tests/CatalogWorkflowRollbackTests.swift index 3113c43e..a1144434 100644 --- a/ShadowsocksX-NG2/Tests/CatalogWorkflowRollbackTests.swift +++ b/ShadowsocksX-NG2/Tests/CatalogWorkflowRollbackTests.swift @@ -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 {