From bd68e45ea4f06d4396e84989ac7c0dd7a9aee9d7 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Mon, 5 Oct 2026 16:41:18 +0800 Subject: [PATCH] feat(servers): present sharing in a popover --- .../CatalogWorkflow/CatalogWorkflow+URI.swift | 18 +++ ShadowsocksX-NG2/App/ServersView+Share.swift | 125 +++++++++++------- ShadowsocksX-NG2/App/ServersView.swift | 27 ++-- docs/design/server-share-popover.md | 19 +++ 4 files changed, 130 insertions(+), 59 deletions(-) create mode 100644 docs/design/server-share-popover.md diff --git a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+URI.swift b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+URI.swift index bf6025af..0e1eeee1 100644 --- a/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+URI.swift +++ b/ShadowsocksX-NG2/App/CatalogWorkflow/CatalogWorkflow+URI.swift @@ -1,8 +1,26 @@ import Foundation +/// 分享顶部的已保存资料,不包含密码或插件参数。 +struct ServerSharePresentation { + let name: String + let address: String + let port: Int + let encryptionMethod: String + let pluginProgram: String? +} + // MARK: - 分享与 URI → 服务器叶子 extension CatalogWorkflow { + func serverSharePresentation(for id: NodeID) -> ServerSharePresentation? { + guard let entry = dependencies.coordinator.committedCatalog.entry(for: id), + case .server(let fields) = entry.kind + else { return nil } + return ServerSharePresentation( + name: entry.displayName, address: fields.address, port: fields.port, + encryptionMethod: fields.encryptionMethod, pluginProgram: fields.pluginProgram) + } + /// 服务器 → ss://(SIP002,story 22):凭据从存储解析;无凭据即点名失败 /// (不分享空档)。仅在用户明确请求分享时调用。 func shareURI(for id: NodeID) throws -> String { diff --git a/ShadowsocksX-NG2/App/ServersView+Share.swift b/ShadowsocksX-NG2/App/ServersView+Share.swift index 058c63db..c031ec49 100644 --- a/ShadowsocksX-NG2/App/ServersView+Share.swift +++ b/ShadowsocksX-NG2/App/ServersView+Share.swift @@ -1,14 +1,11 @@ import SwiftUI -/// 服务器分区的分享与二维码呈现(issue #16/D10,地图 #52 票 #55;2026-10-05 -/// 按草图自工具栏 popover 升级为 sheet,docs/design/server-share-sheet.md): -/// 大幅二维码加复制图片、保存图片、复制 ss:// 三条通路。分享是显式命令, -/// 作用于打开时选中的服务器叶子,载荷与建议文件名随 ShareContext 冻结, -/// sheet 生命周期内不随选择漂移。 +/// 服务器分享由工具栏按钮旁的 popover 呈现;打开时冻结已保存资料和载荷。 extension ServersView { - /// 打开 sheet 即冻结的分享载荷与保存面板建议名。 + /// 打开 popover 即冻结的分享载荷、资料与保存面板建议名。 struct ShareContext: Identifiable { let payload: String + let presentation: ServerSharePresentation let suggestedFileName: String var id: String { payload } } @@ -21,25 +18,28 @@ extension ServersView { } func presentShare() { - guard let selection, let payload = try? workflow.shareURI(for: selection) else { return } + 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: workflow.displayName(for: selection))) + from: presentation.name)) } } -/// 分享 sheet 本体:草图的顶部说明加大幅二维码,三个动作按钮纵向铺满, -/// 「完成」收尾;两个复制动作给瞬时「已复制」反馈,保存由系统面板反馈。 -struct ShareServerSheet: View { +/// 系统分享菜单式分区:服务器资料、二维码、带图标的操作行。 +struct ShareServerPopover: View { let payload: String + let presentation: ServerSharePresentation let suggestedFileName: String let imageClipboard: any ImageClipboard let textClipboard: any TextClipboard let saver: any QrImageSaver let errors: ErrorAlertPresenter - @Environment(\.dismiss) private var dismiss @State private var png: Data? @State private var copyFeedback: CopyFeedback = .none @State private var feedbackResetTask: Task? @@ -50,43 +50,59 @@ struct ShareServerSheet: View { case link } - /// 四枚按钮统一最小宽:所有标签(含「已复制」反馈态)都远小于该值,保证 - /// 视觉等宽,且按草图略窄于二维码块(minWidth 施于标签内侧,bordered 样式 - /// 随标签边界绘制)。 - private static let buttonMinWidth: CGFloat = 260 - var body: some View { - VStack(spacing: 16) { - Text("用其他设备的客户端扫描此二维码") - .font(.headline) + VStack(spacing: 12) { + serverSummary + .padding(.horizontal, 8) + Divider() qrDisplay - VStack(spacing: 8) { - shareButton("复制二维码图片", feedback: .image, disabled: png == nil) { - copyImage() - } - shareButton("保存二维码图片", feedback: nil, disabled: png == nil) { + Divider() + VStack(spacing: 2) { + shareButton( + "保存二维码图片", symbol: "square.and.arrow.down", feedback: nil, + disabled: png == nil + ) { saveImage() } - // ss:// 链接不依赖二维码生成,sheet 打开即可复制。 - shareButton("复制 ss:// 链接", feedback: .link, disabled: false) { + shareButton( + "拷贝二维码图片", symbol: "doc.on.doc", feedback: .image, + disabled: png == nil + ) { + copyImage() + } + shareButton("拷贝 URI 链接", symbol: "link", feedback: .link, disabled: false) { copySsUri() } } - Button { - dismiss() - } label: { - Text("完成") - .frame(minWidth: Self.buttonMinWidth) - } - .keyboardShortcut(.cancelAction) - .buttonStyle(.bordered) } - .padding(24) - .frame(width: 340) + .padding(12) + .frame(width: 324) .task { await generate() } .onDisappear { feedbackResetTask?.cancel() } } + private var serverSummary: some View { + VStack(alignment: .leading, spacing: 6) { + Text(verbatim: presentation.name) + .font(.headline) + Text(verbatim: endpoint) + .foregroundStyle(.secondary) + Text("加密方式:\(presentation.encryptionMethod)") + if let plugin = presentation.pluginProgram, !plugin.isEmpty { + Text("插件:\(plugin)") + } + } + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var endpoint: String { + let address = presentation.address + let host = address.contains(":") && !address.hasPrefix("[") ? "[\(address)]" : address + return "\(host):\(presentation.port)" + } + private var qrDisplay: some View { Group { if let png, let image = NSImage(data: png) { @@ -111,21 +127,23 @@ struct ShareServerSheet: View { private func shareButton( _ title: String, + symbol: String, feedback: CopyFeedback?, disabled: Bool, action: @escaping () -> Void ) -> some View { Button(action: action) { - Group { - if let feedback, copyFeedback == feedback { - Label("已复制", systemImage: "checkmark") - } else { - Text(title) - } + HStack(spacing: 12) { + Image(systemName: copyFeedback == feedback ? "checkmark" : symbol) + .frame(width: 20) + Text(copyFeedback == feedback ? "已复制" : title) + Spacer(minLength: 0) } - .frame(minWidth: Self.buttonMinWidth) + .padding(.horizontal, 8) + .padding(.vertical, 7) + .contentShape(Rectangle()) } - .buttonStyle(.bordered) + .buttonStyle(ShareActionButtonStyle()) .disabled(disabled) } @@ -178,3 +196,20 @@ struct ShareServerSheet: View { } } } + +/// 保留原生 Button 的键盘与辅助功能,悬停时绘制系统菜单式行底色。 +private struct ShareActionButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled + @State private var isHovering = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background { + RoundedRectangle(cornerRadius: 6) + .fill( + Color.primary.opacity(isEnabled && (isHovering || configuration.isPressed) ? 0.1 : 0)) + } + .opacity(isEnabled ? 1 : 0.4) + .onHover { isHovering = $0 } + } +} diff --git a/ShadowsocksX-NG2/App/ServersView.swift b/ShadowsocksX-NG2/App/ServersView.swift index 163446b6..d5e010eb 100644 --- a/ShadowsocksX-NG2/App/ServersView.swift +++ b/ShadowsocksX-NG2/App/ServersView.swift @@ -38,9 +38,7 @@ struct ServersView: View { @State private var moveTarget: NodeID? @State private var rootDropHovering = false @FocusState private var treeFocused: Bool - // 分享 sheet 状态:由 ServersView+Share.swift 扩展驱动;2026-10-05 自工具 - // 栏 popover 升级为 sheet(docs/design/server-share-sheet.md),打开时冻结 - // 载荷与建议文件名。 + // 分享 popover 打开时冻结载荷、顶部资料与建议文件名。 @State var shareContext: ShareContext? var body: some View { @@ -94,16 +92,7 @@ struct ServersView: View { workflow: workflow, errors: errors, parent: newServerParent, onCreated: selectCreatedNode) } - .sheet(item: $shareContext) { context in - ShareServerSheet( - payload: context.payload, - suggestedFileName: context.suggestedFileName, - imageClipboard: imageClipboard, - textClipboard: clipboard, - saver: qrImageSaver, - errors: errors) - } - // 分享 sheet 开着时切换选中项:立即收起,避免内容滞留为旧服务器。 + // 切换选中项时立即收起分享,避免显示旧服务器。 .onChange(of: selection) { shareContext = nil } @@ -300,7 +289,7 @@ extension ServersView { .help("删除") .disabled(!canDeleteSelection) } - // 分享(分享/导出术语见 GLOSSARY.md):作用于选中的服务器叶子,sheet 内 + // 分享(分享/导出术语见 GLOSSARY.md):作用于选中的服务器叶子,popover 内 // 提供二维码、复制/保存图片与复制 ss:// 链接;无有效载荷即不可用。 ToolbarItem(placement: .primaryAction) { Button { @@ -310,6 +299,16 @@ extension ServersView { } .help("分享") .disabled(sharePayload() == nil) + .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) + } } } diff --git a/docs/design/server-share-popover.md b/docs/design/server-share-popover.md new file mode 100644 index 00000000..2df2c20f --- /dev/null +++ b/docs/design/server-share-popover.md @@ -0,0 +1,19 @@ +# 服务器分享 popover(2026-10-05) + +本文件记录本轮用户确认的规格。替代前一轮分享 sheet 的呈现设计;当前实现以代码为准。 + +## 已确认决策 + +- 点击服务器分区工具栏「分享」,在按钮旁打开原生 popover。移除「完成」按钮,点击外部或按 Esc 关闭;切换选中服务器时立即关闭。 +- 顶部依次显示已保存的服务器名称、地址与端口、加密方式、可选插件程序名。无插件时隐藏插件行;名称和地址单行显示、尾部截断。不显示密码或插件参数。 +- 打开时冻结顶部资料、分享 URI 和保存面板建议文件名;不读取编辑中的未保存草稿。 +- 布局为资料、分隔线、二维码、分隔线、三个操作。二维码保持 280×280 和白底衬底。 +- 操作采用系统分享菜单式左对齐整行按钮,悬停显示圆角底色,顺序为「保存二维码图片」(square.and.arrow.down)、「拷贝二维码图片」(doc.on.doc)、「拷贝 URI 链接」(link)。 +- 两个拷贝操作成功后保留约 2 秒「已复制」反馈;保存继续使用系统保存面板。图片未就绪时禁用图片操作;链接可直接拷贝。 +- 分享入口的可用性、二维码生成、图片剪贴板格式、PNG 保存和失败提示继续沿用现有行为。 + +## 实现边界 + +CatalogWorkflow 提供不包含秘密的 ServerSharePresentation 查询。ServersView 在显式分享命令中捕获资料和 URI;ShareServerPopover 自持二维码与反馈状态。平台效果继续经过 ImageClipboard、TextClipboard 和 QrImageSaver。 + +现有 GLOSSARY「服务器分享」定义适用,无新增领域术语。这是可逆的 UI 呈现调整,不新增 ADR。