feat(servers): add group sharing popover

This commit is contained in:
Qiu Yuzhou
2026-10-05 16:57:48 +08:00
parent bd68e45ea4
commit a6ebb69bbf
9 changed files with 333 additions and 35 deletions
+3
View File
@@ -18,6 +18,9 @@
**配置组导出(Configuration group export)**:
用户请求生成的可分享文件快照,包含一个配置组及其全部后代的连接资料和所需凭据。它可以包含不具备激活条件的服务器配置。
**配置组分享(Configuration group share)**:
通过 SIP-008 JSON 文件或逐行 `ss://` URI 列表(文本文件或剪贴板)传递一个配置组及其全部后代服务器的连接资料和所需凭据,供其他客户端使用。URI 列表不保留分组结构。
**服务器分享(Server share)**:
通过二维码(屏幕显示、复制图片或保存图片文件)或剪贴板传递单个服务器配置的 `ss://` URI,供其他设备使用。
_避免用词_:导出(导出指配置组或诊断报告等文件快照)。
@@ -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
@@ -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 }
+2 -2
View File
@@ -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
}
@@ -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)
}
}
}
+127 -18
View File
@@ -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<ConfigurationGroupShareDraft, Error>
}
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<Void, Never>?
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)
}
}
}
+17 -11
View File
@@ -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)
}
}
}
}
@@ -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) }
}
}
@@ -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。