From a70165f5fb136cd2c64085fe1e46f255d5dd4ec4 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Thu, 24 Sep 2026 13:32:15 +0800 Subject: [PATCH] feat(ng2): isolate clipboard and diagnostic export effects (#51) --- ShadowsocksX-NG2/App/DiagnosticsView.swift | 42 +++--- ShadowsocksX-NG2/App/ErrorPresentation.swift | 24 +++ ShadowsocksX-NG2/App/ImportSheets.swift | 5 +- ShadowsocksX-NG2/App/MainApp.swift | 22 ++- ShadowsocksX-NG2/App/MainWindowView.swift | 8 +- .../AppKitDiagnosticReportExporter.swift | 23 +++ .../PlatformEffects/AppKitTextClipboard.swift | 22 +++ .../DiagnosticReportExporter.swift | 65 ++++++++ .../App/PlatformEffects/TextClipboard.swift | 34 +++++ ShadowsocksX-NG2/App/ProxyStatusMenu.swift | 23 ++- ShadowsocksX-NG2/App/ServerDetailView.swift | 10 +- ShadowsocksX-NG2/App/WorkspaceView.swift | 39 +++-- .../Tests/AppPresentationTests.swift | 15 ++ .../Tests/PlatformEffectsTests.swift | 141 ++++++++++++++++++ 14 files changed, 425 insertions(+), 48 deletions(-) create mode 100644 ShadowsocksX-NG2/App/PlatformEffects/AppKitDiagnosticReportExporter.swift create mode 100644 ShadowsocksX-NG2/App/PlatformEffects/AppKitTextClipboard.swift create mode 100644 ShadowsocksX-NG2/App/PlatformEffects/DiagnosticReportExporter.swift create mode 100644 ShadowsocksX-NG2/App/PlatformEffects/TextClipboard.swift create mode 100644 ShadowsocksX-NG2/Tests/PlatformEffectsTests.swift diff --git a/ShadowsocksX-NG2/App/DiagnosticsView.swift b/ShadowsocksX-NG2/App/DiagnosticsView.swift index 82bf6198..49824901 100644 --- a/ShadowsocksX-NG2/App/DiagnosticsView.swift +++ b/ShadowsocksX-NG2/App/DiagnosticsView.swift @@ -1,14 +1,15 @@ import SwiftUI -import UniformTypeIdentifiers /// 主窗口诊断区(spec #21 D11,issue #34/#43):日志查看器(GUI 事件流实时 /// 呈现 + wrapper 收敛日志尾部,均可复制)与显式触发的脱敏诊断导出。事实 /// 采样、轮询代际与报告准备全部经 DiagnosticsWorkflow(issue #43 的唯一 -/// UI-facing seam);本视图只负责生命周期触发、呈现、剪贴板、保存面板与 -/// 报告文件写入——文件实际写入成功后才登记导出完成事件。 +/// UI-facing seam);本视图只负责生命周期触发、呈现与用户触发动作;剪贴板 +/// 和报告文件效果经显式 adapter 注入,文件实际写入成功后才登记导出完成事件。 struct DiagnosticsView: View { @ObservedObject var diagnostics: DiagnosticsWorkflow let errors: ErrorAlertPresenter + let clipboard: any TextClipboard + let reportExporter: any DiagnosticReportExporter enum LogSource: String, CaseIterable, Identifiable { case guiEvents @@ -126,28 +127,29 @@ struct DiagnosticsView: View { } private func copyToClipboard(_ text: String) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) + do { + try clipboard.write(text) + } catch { + errors.present(error) + } } // MARK: - 显式导出(唯一导出入口) private func exportReport() { - let panel = NSSavePanel() - panel.allowedContentTypes = [.plainText] - panel.nameFieldStringValue = diagnostics.suggestedReportFileName() - guard panel.runModal() == .OK, let url = panel.url else { return } - switch diagnostics.prepareReport() { - case .ready(let draft): - do { - try draft.data.write(to: url) - diagnostics.noteExportCompleted() - exportedPath = url.path - } catch { - errors.present(text: "导出失败:\(error.presentableMessage)") - } - case .failed: - errors.present(text: "导出失败:诊断报告无法安全构造") + switch DiagnosticReportExportAction( + diagnostics: diagnostics, exporter: reportExporter + ).perform() + { + case .preparationFailed(let failure): + errors.present(failure) + case .cancelled: + break + case .saved(let url): + diagnostics.noteExportCompleted() + exportedPath = url.path + case .exportFailed(let failure): + errors.present(failure) } } } diff --git a/ShadowsocksX-NG2/App/ErrorPresentation.swift b/ShadowsocksX-NG2/App/ErrorPresentation.swift index ed9eb307..b0adb3f8 100644 --- a/ShadowsocksX-NG2/App/ErrorPresentation.swift +++ b/ShadowsocksX-NG2/App/ErrorPresentation.swift @@ -65,6 +65,12 @@ extension AppPresentation { return systemProxy(error) case let error as SystemProxyOwnershipStoreError: return ownershipStore(error) + case let error as TextClipboardFailure: + return textClipboard(error) + case let error as DiagnosticReportFailure: + return diagnosticReport(error) + case let error as DiagnosticReportExportFailure: + return diagnosticReportExport(error) default: return unknownError } @@ -378,6 +384,24 @@ extension AppPresentation { } } + private static func textClipboard(_ error: TextClipboardFailure) -> String { + switch error { + case .writeFailed: return "剪贴板写入失败,请重试" + } + } + + private static func diagnosticReport(_ error: DiagnosticReportFailure) -> String { + switch error { + case .encodingFailed: return "诊断报告无法安全构造,请重试" + } + } + + private static func diagnosticReportExport(_ error: DiagnosticReportExportFailure) -> String { + switch error { + case .writeFailed: return "诊断报告写入失败,请选择其他位置或重试" + } + } + private static func launchFailure(_ facts: LaunchFailureFacts) -> String { switch facts { case .missingRuntimeDocument: return "缺少运行时文档" diff --git a/ShadowsocksX-NG2/App/ImportSheets.swift b/ShadowsocksX-NG2/App/ImportSheets.swift index 507d6198..cedf550c 100644 --- a/ShadowsocksX-NG2/App/ImportSheets.swift +++ b/ShadowsocksX-NG2/App/ImportSheets.swift @@ -6,6 +6,7 @@ import UniformTypeIdentifiers struct ImportURLSheet: View { let workflow: CatalogWorkflow let errors: ErrorAlertPresenter + let clipboard: any TextClipboard @Binding var selection: NodeID? @Environment(\.dismiss) private var dismiss @@ -25,8 +26,8 @@ struct ImportURLSheet: View { ) HStack { Button("粘贴自剪贴板") { - if let clipboard = NSPasteboard.general.string(forType: .string) { - text = clipboard + if let clipboardText = clipboard.read() { + text = clipboardText } } Spacer() diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index 8eba306d..bfc52fab 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -10,9 +10,13 @@ struct ShadowsocksXNG2App: App { @StateObject private var settingsWorkflow: SettingsWorkflow @StateObject private var diagnosticsWorkflow: DiagnosticsWorkflow @StateObject private var workspaceRoute: WorkspaceRoute + private let textClipboard: any TextClipboard + private let diagnosticReportExporter: any DiagnosticReportExporter init() { let dependencies = ApplicationDependencies.make() + textClipboard = dependencies.textClipboard + diagnosticReportExporter = dependencies.diagnosticReportExporter // The catalog document is read exactly once at startup. The coordinator // publishes later committed snapshots; the controller receives read-only // access to this same in-process source. @@ -77,7 +81,8 @@ struct ShadowsocksXNG2App: App { ProxyStatusMenu( control: proxyControl, catalogWorkflow: catalogWorkflow, - route: workspaceRoute) + route: workspaceRoute, + clipboard: textClipboard) } .menuBarExtraStyle(.menu) @@ -88,7 +93,9 @@ struct ShadowsocksXNG2App: App { proxyController: proxyController, diagnostics: diagnosticsWorkflow, settingsWorkflow: settingsWorkflow, - loginController: loginController) + loginController: loginController, + clipboard: textClipboard, + diagnosticReportExporter: diagnosticReportExporter) } } } @@ -97,6 +104,7 @@ struct ShadowsocksXNG2App: App { /// SwiftUI application. Its bootstrap must nevertheless be hermetic: starting /// XCTest must not read the user's catalog, settings, activation state, Legacy /// defaults, login-item registration, runtime files, or production Keychain. +@MainActor private struct ApplicationDependencies { let credentials: CredentialStoring let catalogFileStore: CatalogFileStore @@ -108,6 +116,8 @@ private struct ApplicationDependencies { let legacyImportService: LegacyImportService let launchAgent: LaunchAgentControlling let loginService: LaunchAtLoginControlling + let textClipboard: any TextClipboard + let diagnosticReportExporter: any DiagnosticReportExporter static var isUnitTesting: Bool { let environment = ProcessInfo.processInfo.environment @@ -138,7 +148,9 @@ private struct ApplicationDependencies { legacyImportService: LegacyImportService( catalogStore: catalogFileStore, credentials: credentials), launchAgent: SMAppLaunchAgentService(), - loginService: SMAppLaunchAtLoginService()) + loginService: SMAppLaunchAtLoginService(), + textClipboard: AppKitTextClipboard(), + diagnosticReportExporter: AppKitDiagnosticReportExporter()) } private static func makeTesting() -> ApplicationDependencies { @@ -174,7 +186,9 @@ private struct ApplicationDependencies { settings: restoredSettings.settings.listen, unreadableError: nil), legacyImportService: legacyImportService, launchAgent: NoopLaunchAgentService(), - loginService: NoopLaunchAtLoginService()) + loginService: NoopLaunchAtLoginService(), + textClipboard: InMemoryTextClipboard(), + diagnosticReportExporter: InMemoryDiagnosticReportExporter()) } } diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index da9ccb13..2b15d85d 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -10,6 +10,7 @@ struct ServersView: View { /// 运行时事实来源:活动目标标记;激活命令经 `workflow.activate`。 @ObservedObject var proxyController: ProxyRuntimeController @Binding var selection: NodeID? + let clipboard: any TextClipboard /// 共享错误弹窗呈现(UI 持有;typed error → 本地化文案的呈现边缘)。 @StateObject private var errors = ErrorAlertPresenter() @@ -76,7 +77,8 @@ struct ServersView: View { Text(deleteMessage) } .sheet(isPresented: $showImportURLSheet) { - ImportURLSheet(workflow: workflow, errors: errors, selection: $selection) + ImportURLSheet( + workflow: workflow, errors: errors, clipboard: clipboard, selection: $selection) } .sheet(isPresented: $showQRImportSheet) { QRImportSheet(workflow: workflow, errors: errors, selection: $selection) @@ -178,7 +180,7 @@ struct ServersView: View { } else { ServerDetailView( workflow: workflow, serverID: id, - errors: errors) + errors: errors, clipboard: clipboard) } } else { ContentUnavailableView( @@ -217,7 +219,7 @@ struct ServersView: View { /// 剪贴板入口:整段剪贴板文本按行解析导入当前落点。 private func importFromClipboard() { - let text = NSPasteboard.general.string(forType: .string) ?? "" + let text = clipboard.read() ?? "" performImport(text) } diff --git a/ShadowsocksX-NG2/App/PlatformEffects/AppKitDiagnosticReportExporter.swift b/ShadowsocksX-NG2/App/PlatformEffects/AppKitDiagnosticReportExporter.swift new file mode 100644 index 00000000..da9d03cb --- /dev/null +++ b/ShadowsocksX-NG2/App/PlatformEffects/AppKitDiagnosticReportExporter.swift @@ -0,0 +1,23 @@ +import AppKit +import UniformTypeIdentifiers + +/// 生产诊断报告 exporter:只接收已由 DiagnosticsWorkflow 脱敏完成的草稿,负责 +/// 保存面板与用户选定 URL 的文件写入。原始 NSError 不跨过此 adapter。 +@MainActor +final class AppKitDiagnosticReportExporter: DiagnosticReportExporter { + func export(draft: DiagnosticReportDraft) -> DiagnosticReportExportResult { + let panel = NSSavePanel() + panel.allowedContentTypes = [.plainText] + panel.nameFieldStringValue = draft.suggestedFileName + guard panel.runModal() == .OK, let url = panel.url else { + return .cancelled + } + + do { + try draft.data.write(to: url) + return .saved(url) + } catch { + return .failed(.writeFailed) + } + } +} diff --git a/ShadowsocksX-NG2/App/PlatformEffects/AppKitTextClipboard.swift b/ShadowsocksX-NG2/App/PlatformEffects/AppKitTextClipboard.swift new file mode 100644 index 00000000..9a7da1f7 --- /dev/null +++ b/ShadowsocksX-NG2/App/PlatformEffects/AppKitTextClipboard.swift @@ -0,0 +1,22 @@ +import AppKit + +/// 生产 AppKit clipboard adapter。所有 NSPasteboard 访问集中在此文件,避免 +/// 每个 UI surface 重复 pasteboard replacement 语义。 +@MainActor +final class AppKitTextClipboard: TextClipboard { + private let pasteboard: NSPasteboard + + init(pasteboard: NSPasteboard = .general) { + self.pasteboard = pasteboard + } + + func read() -> String? { + pasteboard.string(forType: .string) + } + + func write(_ text: String) throws { + guard pasteboard.clearContents() != 0, pasteboard.setString(text, forType: .string) else { + throw TextClipboardFailure.writeFailed + } + } +} diff --git a/ShadowsocksX-NG2/App/PlatformEffects/DiagnosticReportExporter.swift b/ShadowsocksX-NG2/App/PlatformEffects/DiagnosticReportExporter.swift new file mode 100644 index 00000000..5dde05fa --- /dev/null +++ b/ShadowsocksX-NG2/App/PlatformEffects/DiagnosticReportExporter.swift @@ -0,0 +1,65 @@ +import Foundation + +/// 脱敏报告草稿离开 DiagnosticsWorkflow 后的用户选择与写入 seam。报告内容、 +/// 脱敏策略与导出完成事件仍由诊断工作流/呈现层拥有。 +@MainActor +protocol DiagnosticReportExporter { + func export(draft: DiagnosticReportDraft) -> DiagnosticReportExportResult +} + +enum DiagnosticReportExportFailure: Equatable, Error { + case writeFailed +} + +enum DiagnosticReportExportResult: Equatable { + case cancelled + case saved(URL) + case failed(DiagnosticReportExportFailure) +} + +/// 诊断导出动作只编排阶段,不拥有 AppKit 文件效果,也不登记完成事件。调用方 +/// 必须只在 `.saved` 后调用 `DiagnosticsWorkflow.noteExportCompleted()`。 +enum DiagnosticReportExportOutcome: Equatable { + case preparationFailed(DiagnosticReportFailure) + case cancelled + case saved(URL) + case exportFailed(DiagnosticReportExportFailure) +} + +@MainActor +struct DiagnosticReportExportAction { + let diagnostics: DiagnosticsWorkflow + let exporter: any DiagnosticReportExporter + + func perform() -> DiagnosticReportExportOutcome { + switch diagnostics.prepareReport() { + case .failed(let failure): + return .preparationFailed(failure) + case .ready(let draft): + switch exporter.export(draft: draft) { + case .cancelled: + return .cancelled + case .saved(let url): + return .saved(url) + case .failed(let failure): + return .exportFailed(failure) + } + } + } +} + +/// 测试与 Preview 使用的内存实现:记录收到的 draft,但不打开保存面板或写文件。 +@MainActor +final class InMemoryDiagnosticReportExporter: DiagnosticReportExporter { + var result: DiagnosticReportExportResult + private(set) var drafts: [DiagnosticReportDraft] = [] + + init(result: DiagnosticReportExportResult = .cancelled) { + self.result = result + } + + func export(draft: DiagnosticReportDraft) -> DiagnosticReportExportResult { + drafts.append(draft) + return result + } +} diff --git a/ShadowsocksX-NG2/App/PlatformEffects/TextClipboard.swift b/ShadowsocksX-NG2/App/PlatformEffects/TextClipboard.swift new file mode 100644 index 00000000..d0df4157 --- /dev/null +++ b/ShadowsocksX-NG2/App/PlatformEffects/TextClipboard.swift @@ -0,0 +1,34 @@ +import Foundation + +/// 用户主动触发的纯文本剪贴板 seam。读取不到文本是正常的空状态;只有写入 +/// 失败才产生 typed failure。URI 解析、分享策略与敏感信息判断不属于此协议。 +@MainActor +protocol TextClipboard { + func read() -> String? + func write(_ text: String) throws +} + +enum TextClipboardFailure: Equatable, Error { + case writeFailed +} + +/// 测试与 Preview 使用的内存实现:不触碰用户的真实 pasteboard。 +@MainActor +final class InMemoryTextClipboard: TextClipboard { + private(set) var text: String? + var writeFailure: TextClipboardFailure? + + init(text: String? = nil, writeFailure: TextClipboardFailure? = nil) { + self.text = text + self.writeFailure = writeFailure + } + + func read() -> String? { text } + + func write(_ text: String) throws { + if let writeFailure { + throw writeFailure + } + self.text = text + } +} diff --git a/ShadowsocksX-NG2/App/ProxyStatusMenu.swift b/ShadowsocksX-NG2/App/ProxyStatusMenu.swift index c5b8f8c8..e92cd54f 100644 --- a/ShadowsocksX-NG2/App/ProxyStatusMenu.swift +++ b/ShadowsocksX-NG2/App/ProxyStatusMenu.swift @@ -15,6 +15,8 @@ struct ProxyStatusMenu: View { @ObservedObject var control: ProxyControlWorkflow @ObservedObject var catalogWorkflow: CatalogWorkflow @ObservedObject var route: WorkspaceRoute + let clipboard: any TextClipboard + @StateObject private var errors = ErrorAlertPresenter() var body: some View { menuContent @@ -25,6 +27,16 @@ struct ProxyStatusMenu: View { .launch(legacyImportOffer: catalogWorkflow.legacyImportState.shouldOffer), using: WorkspaceWindowOpeningAdapter(openWindow: openWindow)) } + .alert( + "操作失败", + isPresented: Binding( + get: { errors.isPresented }, + set: { if !$0 { errors.dismiss() } }) + ) { + Button("好", role: .cancel) {} + } message: { + Text(errors.message ?? "") + } } @ViewBuilder @@ -85,8 +97,7 @@ struct ProxyStatusMenu: View { // ⑥ 复制 HTTP 导出行:workflow 只提供安全能力,复制是 UI 副作用。 Button("复制 HTTP 导出行") { if let exportLine { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(exportLine, forType: .string) + copyHTTPExportLine(exportLine) } } .disabled(exportLine == nil) @@ -121,6 +132,14 @@ struct ProxyStatusMenu: View { || environment["XCTestSessionIdentifier"] != nil } + private func copyHTTPExportLine(_ line: String) { + do { + try clipboard.write(line) + } catch { + errors.present(error) + } + } + /// 模式选择走同一控制 seam:切换语义与菜单勾选态由同一 snapshot 事实来源 /// 保证一致(issue #47)。 private var modeBinding: Binding { diff --git a/ShadowsocksX-NG2/App/ServerDetailView.swift b/ShadowsocksX-NG2/App/ServerDetailView.swift index 7eaba5c6..cf3abe8e 100644 --- a/ShadowsocksX-NG2/App/ServerDetailView.swift +++ b/ShadowsocksX-NG2/App/ServerDetailView.swift @@ -9,6 +9,7 @@ struct ServerDetailView: View { let workflow: CatalogWorkflow let serverID: NodeID let errors: ErrorAlertPresenter + let clipboard: any TextClipboard @State private var address = "" @State private var port = 8388 @@ -249,10 +250,11 @@ struct ServerDetailView: View { } private func copySsUri() { - guard let uri = qrPayload() else { return } - let board = NSPasteboard.general - board.clearContents() - board.setString(uri, forType: .string) + do { + try clipboard.write(workflow.shareURI(for: serverID)) + } catch { + errors.present(error) + } } private func generateQR() { diff --git a/ShadowsocksX-NG2/App/WorkspaceView.swift b/ShadowsocksX-NG2/App/WorkspaceView.swift index 5ffa1012..28d187a6 100644 --- a/ShadowsocksX-NG2/App/WorkspaceView.swift +++ b/ShadowsocksX-NG2/App/WorkspaceView.swift @@ -9,6 +9,8 @@ struct MainWindowView: View { @ObservedObject var diagnostics: DiagnosticsWorkflow @ObservedObject var settingsWorkflow: SettingsWorkflow @ObservedObject var loginController: LaunchAtLoginController + let clipboard: any TextClipboard + let diagnosticReportExporter: any DiagnosticReportExporter @State private var selection: NodeID? @@ -29,7 +31,8 @@ struct MainWindowView: View { ServersView( workflow: workflow, proxyController: proxyController, - selection: $selection) + selection: $selection, + clipboard: clipboard) case .subscriptions: WorkspaceSubscriptionsView( workflow: workflow, @@ -38,7 +41,10 @@ struct MainWindowView: View { SettingsView(workflow: settingsWorkflow, loginController: loginController) .frame(maxWidth: .infinity, maxHeight: .infinity) case .diagnostics: - WorkspaceDiagnosticsView(diagnostics: diagnostics) + WorkspaceDiagnosticsView( + diagnostics: diagnostics, + clipboard: clipboard, + reportExporter: diagnosticReportExporter) } } @@ -119,23 +125,30 @@ private struct WorkspaceSubscriptionsView: View { private struct WorkspaceDiagnosticsView: View { @ObservedObject var diagnostics: DiagnosticsWorkflow + let clipboard: any TextClipboard + let reportExporter: any DiagnosticReportExporter @StateObject private var errors = ErrorAlertPresenter() var body: some View { NavigationSplitView { DiagnosticsSummarySidebar(workflow: diagnostics) } detail: { - DiagnosticsView(diagnostics: diagnostics, errors: errors) - .alert( - "操作失败", - isPresented: Binding( - get: { errors.isPresented }, - set: { if !$0 { errors.dismiss() } }) - ) { - Button("好", role: .cancel) {} - } message: { - Text(errors.message ?? "") - } + DiagnosticsView( + diagnostics: diagnostics, + errors: errors, + clipboard: clipboard, + reportExporter: reportExporter + ) + .alert( + "操作失败", + isPresented: Binding( + get: { errors.isPresented }, + set: { if !$0 { errors.dismiss() } }) + ) { + Button("好", role: .cancel) {} + } message: { + Text(errors.message ?? "") + } } } } diff --git a/ShadowsocksX-NG2/Tests/AppPresentationTests.swift b/ShadowsocksX-NG2/Tests/AppPresentationTests.swift index 528f2136..23edce4d 100644 --- a/ShadowsocksX-NG2/Tests/AppPresentationTests.swift +++ b/ShadowsocksX-NG2/Tests/AppPresentationTests.swift @@ -107,4 +107,19 @@ final class AppPresentationTests: XCTestCase { XCTAssertEqual(AppPresentation.message(for: SecretError()), AppPresentation.unknownError) } + + func testPlatformEffectFailuresHaveStableSafePresentation() { + let failures: [Error] = [ + TextClipboardFailure.writeFailed, + DiagnosticReportFailure.encodingFailed, + DiagnosticReportExportFailure.writeFailed, + ] + + for failure in failures { + let message = AppPresentation.message(for: failure) + XCTAssertFalse(message.isEmpty, "平台 effect failure 必须有安全呈现:\(failure)") + XCTAssertFalse(message.contains("secret")) + XCTAssertFalse(message.contains("/Users/")) + } + } } diff --git a/ShadowsocksX-NG2/Tests/PlatformEffectsTests.swift b/ShadowsocksX-NG2/Tests/PlatformEffectsTests.swift new file mode 100644 index 00000000..ee2a9206 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/PlatformEffectsTests.swift @@ -0,0 +1,141 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +@MainActor +final class TextClipboardTests: XCTestCase { + func testMissingTextIsNormalEmptyState() { + let clipboard = InMemoryTextClipboard() + + XCTAssertNil(clipboard.read()) + } + + func testPlainTextCanBeWrittenAndReadBack() throws { + let clipboard = InMemoryTextClipboard() + + try clipboard.write("ss://example") + + XCTAssertEqual(clipboard.read(), "ss://example") + } + + func testWriteFailureIsTypedAndDoesNotExposePlatformDetails() { + let clipboard = InMemoryTextClipboard(writeFailure: .writeFailed) + + XCTAssertThrowsError(try clipboard.write("secret")) { error in + XCTAssertEqual(error as? TextClipboardFailure, .writeFailed) + } + } +} + +@MainActor +final class DiagnosticReportExportActionTests: DiagnosticsWorkflowTestCase { + func testCancelledExportDoesNotRecordCompletion() { + let workflow = makeWorkflow(catalog: { DiagnosticCatalogFacts() }) + let exporter = InMemoryDiagnosticReportExporter(result: .cancelled) + + let outcome = DiagnosticReportExportAction( + diagnostics: workflow, exporter: exporter + ).perform() + + XCTAssertEqual(outcome, .cancelled) + XCTAssertEqual(exporter.drafts.count, 1) + XCTAssertTrue(events.snapshot.isEmpty) + } + + func testFailedExportDoesNotRecordCompletion() { + let workflow = makeWorkflow(catalog: { DiagnosticCatalogFacts() }) + let exporter = InMemoryDiagnosticReportExporter(result: .failed(.writeFailed)) + + let outcome = DiagnosticReportExportAction( + diagnostics: workflow, exporter: exporter + ).perform() + + XCTAssertEqual(outcome, .exportFailed(.writeFailed)) + XCTAssertEqual(exporter.drafts.count, 1) + XCTAssertTrue(events.snapshot.isEmpty) + } + + func testSuccessfulExportReturnsSelectedURLBeforeCompletionIsRecorded() throws { + let workflow = makeWorkflow(catalog: { DiagnosticCatalogFacts() }) + let url = workDir.appendingPathComponent("diagnostics.txt") + let exporter = InMemoryDiagnosticReportExporter(result: .saved(url)) + + let outcome = DiagnosticReportExportAction( + diagnostics: workflow, exporter: exporter + ).perform() + + XCTAssertEqual(outcome, .saved(url)) + XCTAssertTrue(events.snapshot.isEmpty) + + workflow.noteExportCompleted() + + let exportedEvents = events.snapshot.filter { entry in + if case .diagnosticsExported = entry.event { return true } + return false + } + XCTAssertEqual(exportedEvents.count, 1) + } + + func testReportPreparationFailureDoesNotOpenExporterOrRecordCompletion() { + let workflow = makeWorkflow( + catalog: { DiagnosticCatalogFacts() }, + render: { _ in nil }) + let exporter = InMemoryDiagnosticReportExporter(result: .cancelled) + + let outcome = DiagnosticReportExportAction( + diagnostics: workflow, exporter: exporter + ).perform() + + XCTAssertEqual(outcome, .preparationFailed(.encodingFailed)) + XCTAssertTrue(exporter.drafts.isEmpty) + XCTAssertTrue(events.snapshot.isEmpty) + } +} + +final class PlatformEffectsArchitectureTests: XCTestCase { + func testClipboardAndDiagnosticExportAppKitSymbolsStayInFocusedAdapters() throws { + let appDirectory = Self.testTargetRoot.appendingPathComponent("App") + let sourceFiles = try Self.swiftSourceFiles(in: appDirectory) + + let pasteboardUsers = try Self.files(containing: "NSPasteboard", in: sourceFiles) + XCTAssertEqual( + pasteboardUsers.map(\.lastPathComponent), ["AppKitTextClipboard.swift"], + "纯文本 pasteboard 访问必须集中在 AppKitTextClipboard adapter") + + let savePanelUsers = try Self.files(containing: "NSSavePanel", in: sourceFiles) + XCTAssertEqual( + savePanelUsers.map(\.lastPathComponent), ["AppKitDiagnosticReportExporter.swift"], + "保存面板访问必须集中在 AppKitDiagnosticReportExporter adapter") + + let reportWriterUsers = try Self.files(containing: ".data.write(to:", in: sourceFiles) + XCTAssertEqual( + reportWriterUsers.map(\.lastPathComponent), ["AppKitDiagnosticReportExporter.swift"], + "诊断报告文件写入必须集中在 AppKitDiagnosticReportExporter adapter") + } + + private static let testTargetRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + + private static func swiftSourceFiles(in directory: URL) throws -> [URL] { + guard + let enumerator = FileManager.default.enumerator( + at: directory, includingPropertiesForKeys: nil) + else { + XCTFail("无法枚举 App 源码目录:\(directory.path)") + return [] + } + return enumerator.compactMap { item in + guard let url = item as? URL, url.pathExtension == "swift" else { return nil } + return url + } + } + + private static func files(containing token: String, in files: [URL]) throws -> [URL] { + var matches: [URL] = [] + for file in files where (try? String(contentsOf: file, encoding: .utf8).contains(token)) == true + { + matches.append(file) + } + return matches + } +}