From a6ebb69bbfbb4685593320667e42dd26b410929d Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Mon, 5 Oct 2026 16:57:48 +0800 Subject: [PATCH] feat(servers): add group sharing popover --- GLOSSARY.md | 3 + .../CatalogWorkflow+GroupExport.swift | 45 ++++++ .../CatalogWorkflowModels.swift | 5 + ShadowsocksX-NG2/App/ErrorPresentation.swift | 4 +- .../ConfigurationGroupFileExporter.swift | 9 +- ShadowsocksX-NG2/App/ServersView+Share.swift | 145 +++++++++++++++--- ShadowsocksX-NG2/App/ServersView.swift | 28 ++-- .../Tests/ConfigurationGroupExportTests.swift | 107 +++++++++++++ .../configuration-group-share-popover.md | 22 +++ 9 files changed, 333 insertions(+), 35 deletions(-) create mode 100644 docs/design/configuration-group-share-popover.md diff --git a/GLOSSARY.md b/GLOSSARY.md index c2f9b1fd..d279c974 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -18,6 +18,9 @@ **配置组导出(Configuration group export)**: 用户请求生成的可分享文件快照,包含一个配置组及其全部后代的连接资料和所需凭据。它可以包含不具备激活条件的服务器配置。 +**配置组分享(Configuration group share)**: +通过 SIP-008 JSON 文件或逐行 `ss://` URI 列表(文本文件或剪贴板)传递一个配置组及其全部后代服务器的连接资料和所需凭据,供其他客户端使用。URI 列表不保留分组结构。 + **服务器分享(Server share)**: 通过二维码(屏幕显示、复制图片或保存图片文件)或剪贴板传递单个服务器配置的 `ss://` URI,供其他设备使用。 _避免用词_:导出(导出指配置组或诊断报告等文件快照)。 diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+GroupExport.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+GroupExport.swift index 23298b63..05613671 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+GroupExport.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+GroupExport.swift @@ -4,6 +4,18 @@ import Foundation struct ConfigurationGroupExportDraft: Equatable { let data: Data let suggestedFileName: String + var format: Format = .json + + enum Format: Equatable { + case json + case uriList + } +} + +struct ConfigurationGroupShareDraft { + let json: ConfigurationGroupExportDraft + let uriList: ConfigurationGroupExportDraft + let uriText: String } enum ConfigurationGroupExportFailure: Equatable, Error { @@ -29,6 +41,29 @@ enum ConfigurationGroupExportFailure: Equatable, Error { } extension CatalogWorkflow { + /// 两种分享格式从同一完整校验结果准备,任何服务器失败都不产生部分输出。 + func configurationGroupShareDraft(for groupID: NodeID) throws -> ConfigurationGroupShareDraft { + let catalog = dependencies.coordinator.committedCatalog + guard let root = catalog.entry(for: groupID) else { + throw ConfigurationGroupExportFailure.groupNotFound + } + guard case .group(let fields) = root.kind else { + throw ConfigurationGroupExportFailure.targetIsNotGroup + } + var builder = SIP008GroupExportDocumentBuilder( + catalog: catalog, credentials: dependencies.credentials) + let json = try builder.encode(rootID: groupID) + let text = builder.uriListText + let name = fields.name.replacingOccurrences(of: "/", with: " ") + .replacingOccurrences(of: ":", with: " ").trimmingCharacters(in: .whitespacesAndNewlines) + let base = name.isEmpty ? "ss-servers" : name + return ConfigurationGroupShareDraft( + json: ConfigurationGroupExportDraft(data: json, suggestedFileName: "\(base).json"), + uriList: ConfigurationGroupExportDraft( + data: Data(text.utf8), suggestedFileName: "\(base).txt", format: .uriList), + uriText: text) + } + /// Prepares a complete SIP-008 v1 snapshot for one group. Credentials are resolved /// here so the returned draft is the only secret-bearing value passed to the exporter. func configurationGroupExportDraft(for groupID: NodeID) throws -> ConfigurationGroupExportDraft { @@ -55,6 +90,16 @@ private struct SIP008GroupExportDocumentBuilder { private var groups: [SIP008GroupDTO] = [] private let identities = SIP008ExportIdentity() + var uriListText: String { + servers.map { + SsUri( + method: $0.method, password: $0.password, host: $0.server, port: $0.serverPort, + pluginProgram: $0.plugin, pluginOptions: $0.pluginOptions, + remark: $0.remarks.isEmpty ? nil : $0.remarks + ).encode() + }.joined(separator: "\n") + } + init(catalog: ConfigurationCatalog, credentials: CredentialStoring) { self.catalog = catalog self.credentials = credentials diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift index 56178daf..c181804d 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflowModels.swift @@ -31,6 +31,11 @@ struct CatalogTreeNode: Identifiable, Equatable { /// 子树快照;服务器叶子为空(与 `children` 的 nil 区分叶子语义并存)。 var childNodes: [CatalogTreeNode] { children ?? [] } + /// 子树服务器配置数,包含存在已知激活阻塞的服务器。 + var serverConfigurationCount: Int { + isGroup ? childNodes.reduce(0) { $0 + $1.serverConfigurationCount } : 1 + } + /// 是否包含服务器配置(含当前节点本身);激活资格与结构性可导出性分离。 var containsServerConfiguration: Bool { if !isGroup { return true } diff --git a/ShadowsocksX-NG2/App/ErrorPresentation.swift b/ShadowsocksX-NG2/App/ErrorPresentation.swift index d2193fb2..557cd8e6 100644 --- a/ShadowsocksX-NG2/App/ErrorPresentation.swift +++ b/ShadowsocksX-NG2/App/ErrorPresentation.swift @@ -77,8 +77,8 @@ extension AppPresentation { return diagnosticReportExport(error) case let error as ConfigurationGroupExportFailure: return configurationGroupExport(error) - case is ConfigurationGroupFileExportFailure: - return "SIP-008 文件写入失败" + case let error as ConfigurationGroupFileExportFailure: + return error == .writeFailed ? "SIP-008 文件写入失败" : "URI 链接列表文本文件写入失败" default: return unknownError } diff --git a/ShadowsocksX-NG2/App/PlatformEffects/ConfigurationGroupFileExporter.swift b/ShadowsocksX-NG2/App/PlatformEffects/ConfigurationGroupFileExporter.swift index fa7b1584..8ab653b6 100644 --- a/ShadowsocksX-NG2/App/PlatformEffects/ConfigurationGroupFileExporter.swift +++ b/ShadowsocksX-NG2/App/PlatformEffects/ConfigurationGroupFileExporter.swift @@ -2,7 +2,7 @@ import AppKit import Foundation import UniformTypeIdentifiers -/// SIP-008 草稿的用户选择与文件写入边界;内容准备由 CatalogWorkflow 拥有。 +/// 配置组 JSON / URI 列表草稿的文件写入边界;内容准备由 CatalogWorkflow 拥有。 @MainActor protocol ConfigurationGroupFileExporter { func export(draft: ConfigurationGroupExportDraft) -> ConfigurationGroupFileExportResult @@ -10,6 +10,7 @@ protocol ConfigurationGroupFileExporter { enum ConfigurationGroupFileExportFailure: Equatable, Error { case writeFailed + case uriListWriteFailed } enum ConfigurationGroupFileExportResult: Equatable { @@ -52,12 +53,12 @@ struct ConfigurationGroupExportAction { } } -/// 生产文件边界:JSON 保存面板与原子文件写入集中在 AppKit adapter。 +/// 生产文件边界:JSON / UTF-8 文本保存面板与原子文件写入集中在 AppKit adapter。 @MainActor final class AppKitConfigurationGroupFileExporter: ConfigurationGroupFileExporter { func export(draft: ConfigurationGroupExportDraft) -> ConfigurationGroupFileExportResult { let panel = NSSavePanel() - panel.allowedContentTypes = [.json] + panel.allowedContentTypes = draft.format == .json ? [.json] : [.plainText] panel.nameFieldStringValue = draft.suggestedFileName guard panel.runModal() == .OK, let url = panel.url else { return .cancelled @@ -67,7 +68,7 @@ final class AppKitConfigurationGroupFileExporter: ConfigurationGroupFileExporter try draft.data.write(to: url, options: .atomic) return .saved(url) } catch { - return .failed(.writeFailed) + return .failed(draft.format == .json ? .writeFailed : .uriListWriteFailed) } } } diff --git a/ShadowsocksX-NG2/App/ServersView+Share.swift b/ShadowsocksX-NG2/App/ServersView+Share.swift index c031ec49..609da523 100644 --- a/ShadowsocksX-NG2/App/ServersView+Share.swift +++ b/ShadowsocksX-NG2/App/ServersView+Share.swift @@ -2,31 +2,55 @@ import SwiftUI /// 服务器分享由工具栏按钮旁的 popover 呈现;打开时冻结已保存资料和载荷。 extension ServersView { - /// 打开 popover 即冻结的分享载荷、资料与保存面板建议名。 - struct ShareContext: Identifiable { + enum ShareContext: Identifiable { + case server(ServerShareContext) + case group(GroupShareContext) + + var id: NodeID { + switch self { + case .server(let context): context.id + case .group(let context): context.id + } + } + } + + struct ServerShareContext { + let id: NodeID let payload: String let presentation: ServerSharePresentation let suggestedFileName: String - var id: String { payload } } - /// 分享载荷:仅服务器叶子可产生(分组与未知节点被 seam 点名拒绝); - /// nil 即分享按钮不可用的统一判据。 - func sharePayload() -> String? { - guard let selection else { return nil } - return try? workflow.shareURI(for: selection) + struct GroupShareContext { + let id: NodeID + let name: String + let serverCount: Int + let draft: Result + } + + var canShareSelection: Bool { + guard let selection, let node = workflow.tree.node(withID: selection) else { return false } + if node.isGroup { return node.containsServerConfiguration } + return (try? workflow.shareURI(for: selection)) != nil } func presentShare() { - guard let selection, - let presentation = workflow.serverSharePresentation(for: selection), - let payload = try? workflow.shareURI(for: selection) - else { return } - shareContext = ShareContext( - payload: payload, - presentation: presentation, - suggestedFileName: QrImageSaveDraft.suggestedFileName( - from: presentation.name)) + guard let selection, let node = workflow.tree.node(withID: selection) else { return } + if node.isGroup { + guard node.containsServerConfiguration else { return } + shareContext = .group( + GroupShareContext( + id: selection, name: node.name, serverCount: node.serverConfigurationCount, + draft: Result { try workflow.configurationGroupShareDraft(for: selection) })) + } else { + guard let presentation = workflow.serverSharePresentation(for: selection), + let payload = try? workflow.shareURI(for: selection) + else { return } + shareContext = .server( + ServerShareContext( + id: selection, payload: payload, presentation: presentation, + suggestedFileName: QrImageSaveDraft.suggestedFileName(from: presentation.name))) + } } } @@ -198,7 +222,7 @@ struct ShareServerPopover: View { } /// 保留原生 Button 的键盘与辅助功能,悬停时绘制系统菜单式行底色。 -private struct ShareActionButtonStyle: ButtonStyle { +struct ShareActionButtonStyle: ButtonStyle { @Environment(\.isEnabled) private var isEnabled @State private var isHovering = false @@ -213,3 +237,88 @@ private struct ShareActionButtonStyle: ButtonStyle { .onHover { isHovering = $0 } } } + +/// 配置组使用同款操作行,分享内容固定为打开时的已保存快照。 +struct ShareGroupPopover: View { + let context: ServersView.GroupShareContext + let exporter: any ConfigurationGroupFileExporter + let clipboard: any TextClipboard + let errors: ErrorAlertPresenter + + @State private var copied = false + @State private var feedbackResetTask: Task? + + var body: some View { + VStack(spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text(verbatim: context.name) + .font(.headline) + .lineLimit(1) + .truncationMode(.tail) + Text("\(context.serverCount) 个服务器") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + Divider() + VStack(spacing: 2) { + action("保存 SIP008 JSON 文件", symbol: "square.and.arrow.down") { + save(.json) + } + action("保存 URI 链接列表文本文件", symbol: "doc.text") { + save(.uriList) + } + action( + copied ? "已复制" : "拷贝 URI 链接列表文本", + symbol: copied ? "checkmark" : "doc.on.doc" + ) { + copyURIList() + } + } + } + .padding(12) + .frame(width: 324) + .onDisappear { feedbackResetTask?.cancel() } + } + + private func action(_ title: String, symbol: String, perform: @escaping () -> Void) -> some View { + Button(action: perform) { + HStack(spacing: 12) { + Image(systemName: symbol).frame(width: 20) + Text(title) + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .padding(.vertical, 7) + .contentShape(Rectangle()) + } + .buttonStyle(ShareActionButtonStyle()) + } + + private func save(_ format: ConfigurationGroupExportDraft.Format) { + do { + let draft = try context.draft.get() + switch exporter.export(draft: format == .json ? draft.json : draft.uriList) { + case .cancelled, .saved: break + case .failed(let failure): errors.present(failure) + } + } catch { + errors.present(error) + } + } + + private func copyURIList() { + do { + try clipboard.write(context.draft.get().uriText) + copied = true + feedbackResetTask?.cancel() + feedbackResetTask = Task { + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + copied = false + } + } catch { + errors.present(error) + } + } +} diff --git a/ShadowsocksX-NG2/App/ServersView.swift b/ShadowsocksX-NG2/App/ServersView.swift index d5e010eb..b084d760 100644 --- a/ShadowsocksX-NG2/App/ServersView.swift +++ b/ShadowsocksX-NG2/App/ServersView.swift @@ -289,8 +289,7 @@ extension ServersView { .help("删除") .disabled(!canDeleteSelection) } - // 分享(分享/导出术语见 GLOSSARY.md):作用于选中的服务器叶子,popover 内 - // 提供二维码、复制/保存图片与复制 ss:// 链接;无有效载荷即不可用。 + // 分享选中的服务器或非空配置组,分别显示二维码或文件/URI 列表操作。 ToolbarItem(placement: .primaryAction) { Button { presentShare() @@ -298,16 +297,23 @@ extension ServersView { Label("分享", systemImage: "square.and.arrow.up") } .help("分享") - .disabled(sharePayload() == nil) + .disabled(!canShareSelection) .popover(item: $shareContext, arrowEdge: .top) { context in - ShareServerPopover( - payload: context.payload, - presentation: context.presentation, - suggestedFileName: context.suggestedFileName, - imageClipboard: imageClipboard, - textClipboard: clipboard, - saver: qrImageSaver, - errors: errors) + switch context { + case .server(let server): + ShareServerPopover( + payload: server.payload, + presentation: server.presentation, + suggestedFileName: server.suggestedFileName, + imageClipboard: imageClipboard, + textClipboard: clipboard, + saver: qrImageSaver, + errors: errors) + case .group(let group): + ShareGroupPopover( + context: group, exporter: configurationGroupFileExporter, + clipboard: clipboard, errors: errors) + } } } } diff --git a/ShadowsocksX-NG2/Tests/ConfigurationGroupExportTests.swift b/ShadowsocksX-NG2/Tests/ConfigurationGroupExportTests.swift index a85f4899..1f23e213 100644 --- a/ShadowsocksX-NG2/Tests/ConfigurationGroupExportTests.swift +++ b/ShadowsocksX-NG2/Tests/ConfigurationGroupExportTests.swift @@ -1,3 +1,4 @@ +import Testing import XCTest @testable import ShadowsocksX_NG2 @@ -269,3 +270,109 @@ final class ConfigurationGroupExportTests: XCTestCase { return try XCTUnwrap(workflow.tree.node(withID: parent)?.childNodes.last?.id) } } + +@Suite("Configuration group sharing") +@MainActor +struct ConfigurationGroupSharingTests { + @Test func nestedURIListPreservesOrderAndCompleteConnectionData() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let draft = try fixture.workflow.configurationGroupShareDraft(for: fixture.root) + let uris = try draft.uriText.split(separator: "\n").map { try SsUri.decode(String($0)) } + #expect(uris.map(\.host) == ["203.0.113.1", "2001:db8::2", "203.0.113.3"]) + #expect(uris.map(\.password) == ["first", "nested", "last"]) + #expect(uris[1].pluginProgram == "missing-plugin") + #expect(uris[1].pluginOptions == "mode=websocket;host=example.com") + #expect(uris[1].remark == "嵌套服务器") + #expect(draft.uriList.data == Data(draft.uriText.utf8)) + #expect(draft.uriList.suggestedFileName == "组 根.txt") + #expect(draft.uriList.format == .uriList) + let node = try #require(fixture.workflow.tree.node(withID: fixture.root)) + #expect(node.serverConfigurationCount == 3) + let document = try #require( + JSONSerialization.jsonObject(with: draft.json.data) as? [String: Any]) + let servers = try #require(document["servers"] as? [[String: Any]]) + #expect(servers.compactMap { $0["server"] as? String } == uris.map(\.host)) + #expect(draft.json.format == .json) + } + + @Test func missingNestedCredentialRejectsWholeShareAndSnapshotRemainsStable() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let snapshot = try fixture.workflow.configurationGroupShareDraft(for: fixture.root) + try fixture.credentials.delete(fixture.nestedPassword) + #expect( + throws: ConfigurationGroupExportFailure.invalidServer(fixture.nestedServer, .missingPassword) + ) { + try fixture.workflow.configurationGroupShareDraft(for: fixture.root) + } + #expect( + try SsUri.decode(String(snapshot.uriText.split(separator: "\n")[1])).password == "nested") + } + + @Test func emptyGroupCannotPrepareShare() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + #expect(throws: ConfigurationGroupExportFailure.noServers) { + try fixture.workflow.configurationGroupShareDraft(for: fixture.empty) + } + #expect(fixture.workflow.tree.node(withID: fixture.empty)?.serverConfigurationCount == 0) + } + + @MainActor + private struct Fixture { + let directory: URL + let credentials = InMemoryCredentialStore() + let root = NodeID.fresh() + let empty = NodeID.fresh() + let nestedServer = NodeID.fresh() + let nestedPassword = CredentialReference.fresh() + let workflow: CatalogWorkflow + + init() throws { + directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "group-share-\(UUID())") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + var catalog = ConfigurationCatalog() + try catalog.addGroup("组/根", id: root) + try catalog.addGroup("空组", id: empty) + let firstPassword = CredentialReference.fresh() + let lastPassword = CredentialReference.fresh() + let options = CredentialReference.fresh() + try credentials.save("first", for: firstPassword) + try credentials.save("nested", for: nestedPassword) + try credentials.save("last", for: lastPassword) + try credentials.save("mode=websocket;host=example.com", for: options) + try catalog.addServer( + ServerFields( + address: "203.0.113.1", port: 8388, + encryptionMethod: "aes-256-gcm", passwordRef: firstPassword, remark: "First"), + id: .fresh(), to: root) + let nested = NodeID.fresh() + try catalog.addGroup("嵌套", id: nested, to: root) + try catalog.addServer( + ServerFields( + address: "2001:db8::2", port: 8389, + encryptionMethod: "aes-256-gcm", passwordRef: nestedPassword, remark: "嵌套服务器", + pluginProgram: "missing-plugin", pluginOptionsRef: options), id: nestedServer, to: nested) + try catalog.addServer( + ServerFields( + address: "203.0.113.3", port: 8390, + encryptionMethod: "aes-256-gcm", passwordRef: lastPassword, remark: "Last"), + id: .fresh(), to: root) + // 外部服务器不得混入所选子树。 + try catalog.addServer( + ServerFields( + address: "203.0.113.99", port: 8388, + encryptionMethod: "aes-256-gcm", passwordRef: firstPassword), id: .fresh()) + let store = CatalogFileStore(fileURL: directory.appendingPathComponent("catalog.json")) + try store.save(CatalogDocument(catalog: catalog)) + workflow = makeCatalogWorkflow( + coordinator: CatalogCommitCoordinator(fileStore: store, runtime: FakeCatalogRuntime()), + credentials: credentials, + subscriptionFetcher: FakeSubscriptionFetcher(behavior: .success(Data()))) + } + + func cleanUp() { try? FileManager.default.removeItem(at: directory) } + } +} diff --git a/docs/design/configuration-group-share-popover.md b/docs/design/configuration-group-share-popover.md new file mode 100644 index 00000000..e6984c5f --- /dev/null +++ b/docs/design/configuration-group-share-popover.md @@ -0,0 +1,22 @@ +# 配置组分享 popover(2026-10-05) + +本文件记录本轮用户确认的需求和规格;当前实现以代码为准。 + +## 已确认规格 + +- 选中配置组时使用服务器分区工具栏「分享」,在按钮旁显示原生 popover。风格与服务器分享一致;点击外部或按 Esc 关闭,切换选中项立即关闭。 +- 顶部为组名(单行尾部截断)、全部后代服务器数量,之后是分隔线与三个左对齐操作行,带 SF Symbol 和圆角悬停底色。 +- 操作依次为「保存 SIP008 JSON 文件」(square.and.arrow.down)、「保存 URI 链接列表文本文件」(doc.text)、「拷贝 URI 链接列表文本」(doc.on.doc)。拷贝成功显示约 2 秒「已复制」。 +- 手动组和订阅组均支持;包含选中组全部嵌套子组的服务器,按深度优先、同级目录顺序排列,排除祖先与兄弟。计数包含不具备激活资格的服务器,空子树禁用分享。 +- JSON 延续 SIP-008 v1 及项目分组扩展;URI 列表每行一个 ss:// URI,保留服务器名称、密码和插件参数,不保留组结构。文本文件为 UTF-8,建议名为 <组名>.txt。 +- 任一服务器不能完整生成分享资料时整个准备失败,不跳过、不输出部分列表。用户点击操作时沿用既有失败提示;准备失败不打开保存面板、不写剪贴板。 +- 分享资料和两种格式在打开时从同一已保存快照生成并冻结。无额外秘密确认弹窗。文件名中的 / 与 : 替换为空格,空名回退 ss-servers。 +- 保留已有组右键菜单「导出为 SIP-008 JSON…」。 + +## Module 与 Interface + +CatalogWorkflow.configurationGroupShareDraft(for:) 是调用方与测试使用的 interface,内部复用既有完整 SIP-008 校验和遍历,返回 JSON 草稿、URI 文本及文件草稿。Popover 不拥有遍历、凭据解析或序列化规则。 + +ConfigurationGroupFileExporter seam 继续承载保存面板与原子写入,草稿格式区分 JSON 和纯文本;生产及内存 adapter 均复用。TextClipboard seam 承载完整列表拷贝。无需引入通用分享框架。 + +GLOSSARY 增补配置组分享定义。既有 ADR-0018 的 SIP-008 导出合同保持适用;新增格式与呈现可逆,不新增 ADR。