mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-29 21:53:43 +00:00
feat(ng2): isolate clipboard and diagnostic export effects (#51)
This commit is contained in:
@@ -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 "缺少运行时文档"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user