feat(ng2): isolate clipboard and diagnostic export effects (#51)

This commit is contained in:
Qiu Yuzhou
2026-09-24 13:32:15 +08:00
parent 0a1aa9a725
commit a70165f5fb
14 changed files with 425 additions and 48 deletions
+22 -20
View File
@@ -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)
}
}
}
@@ -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 "缺少运行时文档"
+3 -2
View File
@@ -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()
+18 -4
View File
@@ -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())
}
}
+5 -3
View File
@@ -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)
}
@@ -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)
}
}
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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
}
}
+21 -2
View File
@@ -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<ProxyMode> {
+6 -4
View File
@@ -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() {
+26 -13
View File
@@ -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 ?? "")
}
}
}
}
@@ -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/"))
}
}
}
@@ -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
}
}