diff --git a/GLOSSARY.md b/GLOSSARY.md index 06f9ec3a..c2f9b1fd 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -19,7 +19,7 @@ 用户请求生成的可分享文件快照,包含一个配置组及其全部后代的连接资料和所需凭据。它可以包含不具备激活条件的服务器配置。 **服务器分享(Server share)**: -通过二维码或剪贴板传递单个服务器配置的 `ss://` URI,供其他设备使用。 +通过二维码(屏幕显示、复制图片或保存图片文件)或剪贴板传递单个服务器配置的 `ss://` URI,供其他设备使用。 _避免用词_:导出(导出指配置组或诊断报告等文件快照)。 **服务器导入(Server import)**: diff --git a/ShadowsocksX-NG2/App/ErrorPresentation.swift b/ShadowsocksX-NG2/App/ErrorPresentation.swift index 46a57bfa..d2193fb2 100644 --- a/ShadowsocksX-NG2/App/ErrorPresentation.swift +++ b/ShadowsocksX-NG2/App/ErrorPresentation.swift @@ -67,6 +67,10 @@ extension AppPresentation { return systemProxy(SystemProxyOperationFailure(error)) case let error as TextClipboardFailure: return textClipboard(error) + case let error as ImageClipboardFailure: + return imageClipboard(error) + case let error as QrImageSaveFailure: + return qrImageSave(error) case let error as DiagnosticReportFailure: return diagnosticReport(error) case let error as DiagnosticReportExportFailure: @@ -327,6 +331,18 @@ extension AppPresentation { } } + private static func imageClipboard(_ error: ImageClipboardFailure) -> String { + switch error { + case .writeFailed: return "剪贴板写入失败,请重试" + } + } + + private static func qrImageSave(_ error: QrImageSaveFailure) -> String { + switch error { + case .writeFailed: return "二维码图片写入失败,请选择其他位置或重试" + } + } + private static func diagnosticReport(_ error: DiagnosticReportFailure) -> String { switch error { case .encodingFailed: return "诊断报告无法安全构造,请重试" diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index 0aeb23d3..cc6afc0c 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -131,6 +131,7 @@ private struct AppComposition { let silentLaunch: SilentLaunchController let expansion: CatalogExpansionState let textClipboard: any TextClipboard + let imageClipboard: any ImageClipboard let rulesWorkflow: RulesWorkflow let workspaceContent: MainWindowView @@ -200,8 +201,10 @@ private struct AppComposition { silentLaunch: silentLaunch, expansion: expansion, clipboard: textClipboard, + imageClipboard: dependencies.imageClipboard, diagnosticReportExporter: dependencies.diagnosticReportExporter, - configurationGroupFileExporter: dependencies.configurationGroupFileExporter) + configurationGroupFileExporter: dependencies.configurationGroupFileExporter, + qrImageSaver: dependencies.qrImageSaver) return AppComposition( controller: controller, catalogWorkflow: catalogWorkflow, @@ -213,6 +216,7 @@ private struct AppComposition { silentLaunch: silentLaunch, expansion: expansion, textClipboard: textClipboard, + imageClipboard: dependencies.imageClipboard, rulesWorkflow: rulesWorkflow, workspaceContent: workspaceContent) } @@ -280,8 +284,10 @@ private struct ApplicationDependencies { let loginService: LaunchAtLoginControlling let silentLaunchStore: SilentLaunchStore let textClipboard: any TextClipboard + let imageClipboard: any ImageClipboard let diagnosticReportExporter: any DiagnosticReportExporter let configurationGroupFileExporter: any ConfigurationGroupFileExporter + let qrImageSaver: any QrImageSaver static func make() -> ApplicationDependencies { // ADR 0017 初版文件形态的迁移(发布链从未包含,仅存量开发机):文件不存在 @@ -308,8 +314,10 @@ private struct ApplicationDependencies { loginService: SMAppLaunchAtLoginService(), silentLaunchStore: SilentLaunchStore(), textClipboard: AppKitTextClipboard(), + imageClipboard: AppKitImageClipboard(), diagnosticReportExporter: AppKitDiagnosticReportExporter(), - configurationGroupFileExporter: AppKitConfigurationGroupFileExporter()) + configurationGroupFileExporter: AppKitConfigurationGroupFileExporter(), + qrImageSaver: AppKitQrImageSaver()) } } diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index 96ba0cc1..41a277d4 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -34,8 +34,10 @@ struct MainWindowView: View { /// 侧栏使用(见 CatalogExpansionState);首页独立保存浏览状态。 let expansion: CatalogExpansionState let clipboard: any TextClipboard + let imageClipboard: any ImageClipboard let diagnosticReportExporter: any DiagnosticReportExporter let configurationGroupFileExporter: any ConfigurationGroupFileExporter + let qrImageSaver: any QrImageSaver @StateObject private var homeServerList = HomeServerListState() /// 激活反馈共享状态:首页目标树、侧栏右键与分组详情三个激活入口共用。 @@ -126,7 +128,9 @@ struct MainWindowView: View { selection: $selection, expansion: expansion, clipboard: clipboard, - configurationGroupFileExporter: configurationGroupFileExporter) + imageClipboard: imageClipboard, + configurationGroupFileExporter: configurationGroupFileExporter, + qrImageSaver: qrImageSaver) case .subscriptions: SubscriptionsView( workflow: workflow, diff --git a/ShadowsocksX-NG2/App/PlatformEffects/AppKitImageClipboard.swift b/ShadowsocksX-NG2/App/PlatformEffects/AppKitImageClipboard.swift new file mode 100644 index 00000000..522f6223 --- /dev/null +++ b/ShadowsocksX-NG2/App/PlatformEffects/AppKitImageClipboard.swift @@ -0,0 +1,22 @@ +import AppKit + +/// 生产 AppKit image clipboard adapter,与 AppKitTextClipboard 同为 pasteboard +/// replacement 语义的集中点。PNG 为规范类型,另附 TIFF 兼容类型(部分接收方 +/// 如聊天应用只认 TIFF);TIFF 附加失败不影响 PNG 已写入这一结果。 +@MainActor +final class AppKitImageClipboard: ImageClipboard { + private let pasteboard: NSPasteboard + + init(pasteboard: NSPasteboard = .general) { + self.pasteboard = pasteboard + } + + func write(_ pngData: Data) throws { + guard pasteboard.clearContents() != 0, pasteboard.setData(pngData, forType: .png) else { + throw ImageClipboardFailure.writeFailed + } + if let image = NSImage(data: pngData), let tiff = image.tiffRepresentation { + _ = pasteboard.setData(tiff, forType: .tiff) + } + } +} diff --git a/ShadowsocksX-NG2/App/PlatformEffects/ImageClipboard.swift b/ShadowsocksX-NG2/App/PlatformEffects/ImageClipboard.swift new file mode 100644 index 00000000..9924113e --- /dev/null +++ b/ShadowsocksX-NG2/App/PlatformEffects/ImageClipboard.swift @@ -0,0 +1,31 @@ +import Foundation + +/// 用户主动触发的图片剪贴板 seam:以 PNG 数据为规范输入写入剪贴板。生产 +/// adapter 负责平台类型映射(PNG 之外附加哪些兼容类型),协议不感知;读取 +/// 侧暂无消费者,协议不设 read。分享策略与二维码生成不属于此协议。 +@MainActor +protocol ImageClipboard { + func write(_ pngData: Data) throws +} + +enum ImageClipboardFailure: Equatable, Error { + case writeFailed +} + +/// 测试与 Preview 使用的内存实现:不触碰用户的真实 pasteboard。 +@MainActor +final class InMemoryImageClipboard: ImageClipboard { + private(set) var pngData: Data? + var writeFailure: ImageClipboardFailure? + + init(writeFailure: ImageClipboardFailure? = nil) { + self.writeFailure = writeFailure + } + + func write(_ pngData: Data) throws { + if let writeFailure { + throw writeFailure + } + self.pngData = pngData + } +} diff --git a/ShadowsocksX-NG2/App/PlatformEffects/QrImageSaver.swift b/ShadowsocksX-NG2/App/PlatformEffects/QrImageSaver.swift new file mode 100644 index 00000000..aad42719 --- /dev/null +++ b/ShadowsocksX-NG2/App/PlatformEffects/QrImageSaver.swift @@ -0,0 +1,76 @@ +import AppKit +import Foundation +import UniformTypeIdentifiers + +/// 二维码图片保存的用户选择与文件写入边界;PNG 数据与建议文件名由 UI 准备。 +/// 命名用「保存/ saver」而不用导出(导出术语保留给配置组与诊断报告文件快照, +/// GLOSSARY.md)。 +@MainActor +protocol QrImageSaver { + func save(_ draft: QrImageSaveDraft) -> QrImageSaveResult +} + +struct QrImageSaveDraft: Equatable { + /// 二维码 PNG 数据;字段名与 ConfigurationGroupExportDraft.data 同法, + /// 使文件写入调用命中架构守卫的 `.data.write(to:` 聚焦 token。 + let data: Data + let suggestedFileName: String + + /// 保存面板默认文件名:显示名中的路径非法字符(/ 与 :)替换为空格并去首尾 + /// 空白,清洗后为空回退固定名,保证面板总有可编辑的初始名。 + static func suggestedFileName(from displayName: String) -> String { + let cleaned = + displayName + .replacingOccurrences(of: "/", with: " ") + .replacingOccurrences(of: ":", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + let base = cleaned.isEmpty ? "ss-qrcode" : cleaned + return "\(base).png" + } +} + +enum QrImageSaveFailure: Equatable, Error { + case writeFailed +} + +enum QrImageSaveResult: Equatable { + case cancelled + case saved(URL) + case failed(QrImageSaveFailure) +} + +/// 生产文件边界:PNG 保存面板与原子文件写入集中在此 AppKit adapter。 +@MainActor +final class AppKitQrImageSaver: QrImageSaver { + func save(_ draft: QrImageSaveDraft) -> QrImageSaveResult { + let panel = NSSavePanel() + panel.allowedContentTypes = [.png] + panel.nameFieldStringValue = draft.suggestedFileName + guard panel.runModal() == .OK, let url = panel.url else { + return .cancelled + } + + do { + try draft.data.write(to: url, options: .atomic) + return .saved(url) + } catch { + return .failed(.writeFailed) + } + } +} + +/// Tests and previews receive the draft without opening a panel or writing a file. +@MainActor +final class InMemoryQrImageSaver: QrImageSaver { + var result: QrImageSaveResult + private(set) var drafts: [QrImageSaveDraft] = [] + + init(result: QrImageSaveResult = .cancelled) { + self.result = result + } + + func save(_ draft: QrImageSaveDraft) -> QrImageSaveResult { + drafts.append(draft) + return result + } +} diff --git a/ShadowsocksX-NG2/App/ServersView+Share.swift b/ShadowsocksX-NG2/App/ServersView+Share.swift index 85fd231e..058c63db 100644 --- a/ShadowsocksX-NG2/App/ServersView+Share.swift +++ b/ShadowsocksX-NG2/App/ServersView+Share.swift @@ -1,29 +1,16 @@ import SwiftUI -/// 服务器分区的分享与二维码呈现(issue #16/D10,地图 #52 票 #55): -/// 复制 ss:// 与二维码弹窗;分享是显式命令,二维码在后台线程生成。 -/// 2026-10-04 自详情区底部「复制 ss:// 链接」「二维码…」两按钮上收为分区 -/// 工具栏「分享」按钮,作用于当前选中的服务器叶子:分组与空选择被 seam -/// 点名拒绝,凭据无法解析同样不可用,两者共用 nil 载荷这一 disabled 判据。 +/// 服务器分区的分享与二维码呈现(issue #16/D10,地图 #52 票 #55;2026-10-05 +/// 按草图自工具栏 popover 升级为 sheet,docs/design/server-share-sheet.md): +/// 大幅二维码加复制图片、保存图片、复制 ss:// 三条通路。分享是显式命令, +/// 作用于打开时选中的服务器叶子,载荷与建议文件名随 ShareContext 冻结, +/// sheet 生命周期内不随选择漂移。 extension ServersView { - var qrPopover: some View { - VStack(spacing: 12) { - if let qrImage { - Image(nsImage: qrImage) - .interpolation(.none) - .resizable() - .scaledToFit() - .frame(width: 220, height: 220) - } else { - ProgressView() - .frame(width: 220, height: 220) - } - Text("用其他设备的客户端扫描此二维码") - .font(.footnote) - .foregroundStyle(.secondary) - Button("复制 ss:// 链接") { copySsUri() } - } - .padding(20) + /// 打开 sheet 即冻结的分享载荷与保存面板建议名。 + struct ShareContext: Identifiable { + let payload: String + let suggestedFileName: String + var id: String { payload } } /// 分享载荷:仅服务器叶子可产生(分组与未知节点被 seam 点名拒绝); @@ -33,25 +20,161 @@ extension ServersView { return try? workflow.shareURI(for: selection) } - func copySsUri() { - guard let payload = sharePayload() else { return } + func presentShare() { + guard let selection, let payload = try? workflow.shareURI(for: selection) else { return } + shareContext = ShareContext( + payload: payload, + suggestedFileName: QrImageSaveDraft.suggestedFileName( + from: workflow.displayName(for: selection))) + } +} + +/// 分享 sheet 本体:草图的顶部说明加大幅二维码,三个动作按钮纵向铺满, +/// 「完成」收尾;两个复制动作给瞬时「已复制」反馈,保存由系统面板反馈。 +struct ShareServerSheet: View { + let payload: String + 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? + + enum CopyFeedback: Equatable { + case none + case image + case link + } + + /// 四枚按钮统一最小宽:所有标签(含「已复制」反馈态)都远小于该值,保证 + /// 视觉等宽,且按草图略窄于二维码块(minWidth 施于标签内侧,bordered 样式 + /// 随标签边界绘制)。 + private static let buttonMinWidth: CGFloat = 260 + + var body: some View { + VStack(spacing: 16) { + Text("用其他设备的客户端扫描此二维码") + .font(.headline) + qrDisplay + VStack(spacing: 8) { + shareButton("复制二维码图片", feedback: .image, disabled: png == nil) { + copyImage() + } + shareButton("保存二维码图片", feedback: nil, disabled: png == nil) { + saveImage() + } + // ss:// 链接不依赖二维码生成,sheet 打开即可复制。 + shareButton("复制 ss:// 链接", feedback: .link, disabled: false) { + copySsUri() + } + } + Button { + dismiss() + } label: { + Text("完成") + .frame(minWidth: Self.buttonMinWidth) + } + .keyboardShortcut(.cancelAction) + .buttonStyle(.bordered) + } + .padding(24) + .frame(width: 340) + .task { await generate() } + .onDisappear { feedbackResetTask?.cancel() } + } + + private var qrDisplay: some View { + Group { + if let png, let image = NSImage(data: png) { + Image(nsImage: image) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 280, height: 280) + } else { + ProgressView() + .frame(width: 280, height: 280) + } + } + // 白底衬底保证深色外观下的扫码对比度;阴影让浅色外观下也有边界。 + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.white) + .shadow(color: .black.opacity(0.15), radius: 2, y: 1) + ) + } + + private func shareButton( + _ title: 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) + } + } + .frame(minWidth: Self.buttonMinWidth) + } + .buttonStyle(.bordered) + .disabled(disabled) + } + + /// 二维码在后台线程生成(重置即先呈进度,不闪现上一台服务器的码)。 + private func generate() async { + let payload = payload + let data = await Task.detached(priority: .userInitiated) { + try? QrCodeCodec.generatePNG(for: payload) + }.value + png = data + } + + private func copyImage() { + guard let png else { return } do { - try clipboard.write(payload) + try imageClipboard.write(png) + flash(.image) } catch { errors.present(error) } } - func generateQR() { - guard let payload = sharePayload() else { return } - // 重置旧图:换选中项重开弹窗时先呈进度,不闪现上一台服务器的二维码。 - qrImage = nil - showQR = true - Task.detached(priority: .userInitiated) { - let image = (try? QrCodeCodec.generatePNG(for: payload)).flatMap { NSImage(data: $0) } - await MainActor.run { - qrImage = image - } + private func saveImage() { + guard let png else { return } + switch saver.save(QrImageSaveDraft(data: png, suggestedFileName: suggestedFileName)) { + case .cancelled, .saved: + break // 保存与取消都由系统面板给出反馈 + case .failed(let failure): + errors.present(failure) + } + } + + private func copySsUri() { + do { + try textClipboard.write(payload) + flash(.link) + } catch { + errors.present(error) + } + } + + /// 复制成功反馈:标签短暂换成「已复制」,重复点击重置计时。 + private func flash(_ feedback: CopyFeedback) { + copyFeedback = feedback + feedbackResetTask?.cancel() + feedbackResetTask = Task { + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + copyFeedback = .none } } } diff --git a/ShadowsocksX-NG2/App/ServersView.swift b/ShadowsocksX-NG2/App/ServersView.swift index aad8e721..163446b6 100644 --- a/ShadowsocksX-NG2/App/ServersView.swift +++ b/ShadowsocksX-NG2/App/ServersView.swift @@ -19,7 +19,9 @@ struct ServersView: View { /// 切换存续;OutlineGroup 的内建展开态会随分区切换丢失,侧栏因此自管折叠。 @ObservedObject var expansion: CatalogExpansionState let clipboard: any TextClipboard + let imageClipboard: any ImageClipboard let configurationGroupFileExporter: any ConfigurationGroupFileExporter + let qrImageSaver: any QrImageSaver /// 共享错误弹窗呈现(UI 持有;typed error → 本地化文案的呈现边缘)。 /// 非 private:ServersView+Share.swift 扩展经它呈现分享失败。 @@ -36,10 +38,10 @@ struct ServersView: View { @State private var moveTarget: NodeID? @State private var rootDropHovering = false @FocusState private var treeFocused: Bool - // 分享/二维码状态:由 ServersView+Share.swift 扩展驱动,弹窗锚在工具栏 - // 分享按钮上(原详情区底部分享区已于 2026-10-04 上收至此)。 - @State var showQR = false - @State var qrImage: NSImage? + // 分享 sheet 状态:由 ServersView+Share.swift 扩展驱动;2026-10-05 自工具 + // 栏 popover 升级为 sheet(docs/design/server-share-sheet.md),打开时冻结 + // 载荷与建议文件名。 + @State var shareContext: ShareContext? var body: some View { // 主窗口外壳(票 #53)已提供分栏框架;本分区用扁平双栏承载目录与详情 @@ -92,6 +94,19 @@ 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 + } } private var serverSidebar: some View { @@ -285,23 +300,16 @@ extension ServersView { .help("删除") .disabled(!canDeleteSelection) } - // 分享(分享/导出术语见 GLOSSARY.md):作用于选中的服务器叶子,弹窗内 - // 提供二维码与复制 ss:// 链接两条通路;无有效载荷即不可用。 + // 分享(分享/导出术语见 GLOSSARY.md):作用于选中的服务器叶子,sheet 内 + // 提供二维码、复制/保存图片与复制 ss:// 链接;无有效载荷即不可用。 ToolbarItem(placement: .primaryAction) { Button { - generateQR() + presentShare() } label: { Label("分享", systemImage: "square.and.arrow.up") } .help("分享") .disabled(sharePayload() == nil) - .popover(isPresented: $showQR) { - qrPopover - } - // 弹窗开着时切换选中项:立即收起,避免内容滞留为旧服务器。 - .onChange(of: selection) { - showQR = false - } } } diff --git a/ShadowsocksX-NG2/Tests/PlatformEffectsTests.swift b/ShadowsocksX-NG2/Tests/PlatformEffectsTests.swift index f22c46a2..bdf25a48 100644 --- a/ShadowsocksX-NG2/Tests/PlatformEffectsTests.swift +++ b/ShadowsocksX-NG2/Tests/PlatformEffectsTests.swift @@ -27,6 +27,53 @@ final class TextClipboardTests: XCTestCase { } } +@MainActor +final class ImageClipboardTests: XCTestCase { + func testWrittenPNGIsStored() throws { + let clipboard = InMemoryImageClipboard() + + try clipboard.write(Data([0x89, 0x50])) + + XCTAssertEqual(clipboard.pngData, Data([0x89, 0x50])) + } + + func testWriteFailureIsTypedAndDoesNotExposePlatformDetails() { + let clipboard = InMemoryImageClipboard(writeFailure: .writeFailed) + + XCTAssertThrowsError(try clipboard.write(Data())) { error in + XCTAssertEqual(error as? ImageClipboardFailure, .writeFailed) + } + } +} + +@MainActor +final class QrImageSaverTests: XCTestCase { + func testDraftIsPassedThroughToSaver() { + let saver = InMemoryQrImageSaver() + let draft = QrImageSaveDraft(data: Data([0x01]), suggestedFileName: "机房.png") + + let outcome = saver.save(draft) + + XCTAssertEqual(outcome, .cancelled) + XCTAssertEqual(saver.drafts, [draft]) + } + + func testSuggestedFileNameReplacesPathReservedCharacters() { + XCTAssertEqual( + QrImageSaveDraft.suggestedFileName(from: "机房/一号:备用"), + "机房 一号 备用.png") + } + + func testSuggestedFileNameFallsBackWhenCleaningLeavesNothing() { + XCTAssertEqual(QrImageSaveDraft.suggestedFileName(from: " /: "), "ss-qrcode.png") + XCTAssertEqual(QrImageSaveDraft.suggestedFileName(from: ""), "ss-qrcode.png") + } + + func testSuggestedFileNameKeepsCleanNameAndAppendsPNGExtension() { + XCTAssertEqual(QrImageSaveDraft.suggestedFileName(from: " 家里 "), "家里.png") + } +} + @MainActor final class DiagnosticReportExportActionTests: DiagnosticsWorkflowTestCase { func testCancelledExportDoesNotRecordCompletion() { @@ -99,20 +146,27 @@ final class PlatformEffectsArchitectureTests: XCTestCase { let pasteboardUsers = try Self.files(containing: "NSPasteboard", in: sourceFiles) XCTAssertEqual( - pasteboardUsers.map(\.lastPathComponent), ["AppKitTextClipboard.swift"], - "纯文本 pasteboard 访问必须集中在 AppKitTextClipboard adapter") + pasteboardUsers.map(\.lastPathComponent).sorted(), + ["AppKitImageClipboard.swift", "AppKitTextClipboard.swift"], + "pasteboard 访问必须集中在 clipboard adapter(文本与图片各一)") let savePanelUsers = try Self.files(containing: "NSSavePanel", in: sourceFiles) XCTAssertEqual( Set(savePanelUsers.map(\.lastPathComponent)), - Set(["AppKitDiagnosticReportExporter.swift", "ConfigurationGroupFileExporter.swift"]), - "保存面板访问必须集中在对应的 AppKit exporter adapter") + Set([ + "AppKitDiagnosticReportExporter.swift", "ConfigurationGroupFileExporter.swift", + "QrImageSaver.swift", + ]), + "保存面板访问必须集中在对应的 AppKit exporter/saver adapter") let reportWriterUsers = try Self.files(containing: ".data.write(to:", in: sourceFiles) XCTAssertEqual( Set(reportWriterUsers.map(\.lastPathComponent)), - Set(["AppKitDiagnosticReportExporter.swift", "ConfigurationGroupFileExporter.swift"]), - "文件写入必须集中在对应的 AppKit exporter adapter") + Set([ + "AppKitDiagnosticReportExporter.swift", "ConfigurationGroupFileExporter.swift", + "QrImageSaver.swift", + ]), + "文件写入必须集中在对应的 AppKit exporter/saver adapter") } private static let testTargetRoot = URL(fileURLWithPath: #filePath) diff --git a/docs/design/server-share-sheet.md b/docs/design/server-share-sheet.md new file mode 100644 index 00000000..650e5c56 --- /dev/null +++ b/docs/design/server-share-sheet.md @@ -0,0 +1,26 @@ +# 服务器分享 sheet(2026-10-05) + +本文件记录本轮已确认规格(按用户提供的草图方向实现),现状以代码为准。 + +## 需求 + +服务器分区的「分享」由工具栏 popover(220×220 二维码 + 复制 ss:// 链接)升级为窗口 sheet,按草图布局:顶部说明文字、大幅二维码、三个纵向按钮(复制二维码图片 / 保存二维码图片 / 复制 ss:// 链接)。入口与可用性判据不变:仅服务器分区工具栏「分享」按钮,作用于选中的服务器叶子,无有效载荷即禁用。 + +## 已确认决策 + +- 呈现:popover 整体替换为 `.sheet(item:)`;打开时冻结分享载荷与建议文件名(`ShareContext`),sheet 生命周期内不随选择漂移;选中项变化时立即收起。Esc 与「完成」按钮均可关闭。 +- 二维码:显示 280×280,白底圆角衬底保证深色外观下的扫码对比度;后台线程生成,就绪前呈进度,图片动作禁用。生成参数不变(`QrCodeCodec.generatePNG`,CIQRCodeGenerator,纠错级 M,scale 8)。 +- 复制二维码图片:同一 pasteboard 写 PNG(规范类型)+ TIFF(兼容类型,附加失败不影响 PNG 已写入);不附带 ss:// 文本。 +- 保存二维码图片:只提供 PNG,走 NSSavePanel;默认文件名取服务器显示名,路径非法字符(/ 与 :)替换为空格并去首尾空白,清洗后为空回退 `ss-qrcode`,统一追加 `.png`。 +- 复制反馈:两个复制动作成功后按钮文案短暂变为「✓ 已复制」约 2 秒,重复点击重置计时;保存由系统面板反馈,不做额外提示。 +- 按钮:四枚按钮(三个动作 + 完成)标签统一最小宽 260pt(略窄于二维码块,最长标签与反馈态均小于该值),居中排布,宽度不随文案切换跳变。 +- 术语:保存二维码图片文件属于「分享」,不叫导出(GLOSSARY.md 的导出保留给配置组/诊断报告文件快照);新 seam 命名用 saver。GLOSSARY「服务器分享」条目已更新。 + +## 设计 + +- 新增平台 seam(与既有 TextClipboard / ConfigurationGroupFileExporter 同法,协议 + InMemory + AppKit adapter,`NSPasteboard`/`NSSavePanel`/文件写入仍集中在聚焦 adapter,守卫见 `PlatformEffectsArchitectureTests`): + - `ImageClipboard` / `InMemoryImageClipboard` / `AppKitImageClipboard`:图片剪贴板写入。 + - `QrImageSaver` / `InMemoryQrImageSaver` / `AppKitQrImageSaver`(含 `QrImageSaveDraft.suggestedFileName(from:)` 命名清洗):二维码图片保存面板与原子写入。 +- `ShareServerSheet`(`ServersView+Share.swift`)自持二维码与反馈状态;错误沿用共享 `ErrorAlertPresenter` 呈现边缘(与 NewGroupSheet 同法)。 +- 注入链:组合根 `ApplicationDependencies` → `MainWindowView` → `ServersView` → sheet。 +- typed failure 在 `AppPresentation.message(for:)` 白名单登记:图片剪贴板写入失败、二维码图片写入失败。