From 8b698a0bf176a5a9e366aa5f65f6e10e62b08836 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Tue, 29 Sep 2026 15:12:53 +0800 Subject: [PATCH] feat(app): adaptive app form with window-following activation policy - switch to regular while the workspace window is open (Dock icon, app switcher, default menu bar) and back to accessory when it closes; driven by NSWindow didBecomeKey/willClose with an anchor-view identity filter (scenePhase follows the app rather than windows on macOS, so it fires no event on close) - add silent launch preference (settings-general, off by default, persisted in its own silent-launch.json since settings.json is rewritten whole from the controller's in-memory snapshot): suppressed launches keep the menu-bar baseline and proxy runtime restoration is unaffected - docs: CONTEXT.md adaptive app form / silent launch terms and invariants; ADR 0017 superseding 0016 (scene-based model and reopening carry over) --- CONTEXT.md | 9 +- ShadowsocksX-NG2/App/MainApp.swift | 40 +++++- ShadowsocksX-NG2/App/MainWindowView.swift | 8 +- ShadowsocksX-NG2/App/SettingsView.swift | 19 ++- .../App/SilentLaunchController.swift | 30 +++++ .../App/WindowActivationPolicy.swift | 120 ++++++++++++++++++ .../Domain/SilentLaunchStore.swift | 69 ++++++++++ .../Tests/SilentLaunchControllerTests.swift | 69 ++++++++++ .../Tests/SilentLaunchStoreTests.swift | 65 ++++++++++ .../Tests/WindowActivationPolicyTests.swift | 94 ++++++++++++++ docs/adr/0016-menu-bar-only-app-form.md | 2 +- docs/adr/0017-adaptive-app-form.md | 22 ++++ 12 files changed, 533 insertions(+), 14 deletions(-) create mode 100644 ShadowsocksX-NG2/App/SilentLaunchController.swift create mode 100644 ShadowsocksX-NG2/App/WindowActivationPolicy.swift create mode 100644 ShadowsocksX-NG2/Domain/SilentLaunchStore.swift create mode 100644 ShadowsocksX-NG2/Tests/SilentLaunchControllerTests.swift create mode 100644 ShadowsocksX-NG2/Tests/SilentLaunchStoreTests.swift create mode 100644 ShadowsocksX-NG2/Tests/WindowActivationPolicyTests.swift create mode 100644 docs/adr/0017-adaptive-app-form.md diff --git a/CONTEXT.md b/CONTEXT.md index a556f161..84ec3fa2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -43,8 +43,9 @@ - **Window-local default action shortcut**: A keyboard action bound to the focused app window or form context, such as confirming a dialog; it is distinct from a system-level shortcut. - **Workspace destination**: A top-level location in the single 2.0 GUI workspace, such as home, servers, subscriptions, settings, or diagnostics. It is navigation state only; it does not contain a node selection, sheet, alert, or feature draft. - **Workspace route**: The in-process navigation choice and intent used to move between workspace destinations from the workspace or another app entry point. It does not persist user configuration or own feature-local state. -- **Workspace window**: The single main window hosting the workspace. It is presented at every launch and, once closed, can be reopened only from the status menu; opening, closing, or hiding it does not change the app's presentation. -- **Menu bar form**: The app's one constant presentation: a menu-bar-only app with the status menu always available and never a Dock icon or app-switcher presence, whether or not the workspace window is open. +- **Workspace window**: The single main window hosting the workspace. It is presented at every launch unless silent launch is on; while it is open the app presents in its regular form, and once it is closed the status menu is the only way to reopen it. +- **Adaptive app form**: The app's presentation, which follows the workspace window: a menu-bar-only accessory app with the status menu always available and no Dock icon or app-switcher presence while the workspace window is closed, and a regular app with a Dock icon while it is open. _Avoid_: menu bar form (the presentation is no longer constant). +- **Silent launch**: The user preference that a launch present no workspace window, starting in the menu-bar baseline instead. It defaults to off, takes effect on the next launch, and changes nothing about proxy runtime restoration or the login item. ## Relationships and invariants @@ -91,5 +92,5 @@ - Preferences have no single restore-all-factory-defaults action. Where a reset exists, it is scoped to one configuration item, never to the whole preference set at once. - The proxy mode selector's choice and the rule default action persist with the settings snapshot and survive GUI restarts; a persistence failure keeps the previous mode and sub-option in force and names the reason. Rule, global, and direct mode are always available. - The GUI is the user-facing manager; the Shadowsocks tunnel service is an external runtime boundary rather than part of the GUI's domain model. -- The app presents only in the menu bar form: it never gains a Dock icon or app-switcher presence, and its presentation does not change when the workspace window opens, closes, or hides; the GUI process keeps running with the status menu available regardless of the workspace window. -- Every launch of the GUI presents the workspace window; after it is closed, the status menu is the only way to reopen it. +- The app presents in the adaptive app form: menu-bar-only while the workspace window is closed, and with a Dock icon and app-switcher presence while it is open; hiding the app is not closing it and does not change the form; the GUI process keeps running with the status menu available regardless of the workspace window. +- At launch the GUI presents the workspace window unless silent launch is on; after the workspace window is closed, the status menu is the only way to reopen it. Silent launch changes only window presentation: proxy runtime restoration happens at every launch regardless of it. diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index e0b09984..483f7028 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI @main @@ -9,9 +10,13 @@ struct ShadowsocksXNG2App: App { @StateObject private var settingsWorkflow: SettingsWorkflow @StateObject private var diagnosticsWorkflow: DiagnosticsWorkflow @StateObject private var workspaceRoute: WorkspaceRoute + @StateObject private var silentLaunch: SilentLaunchController private let textClipboard: any TextClipboard private let diagnosticReportExporter: any DiagnosticReportExporter private let workspaceContent: MainWindowView + /// 启动呈现行为在进程内一次性定格(ADR 0017):静默启动偏好关闭(默认) + /// 时 presented,开启时 suppressed。切换偏好当次会话无影响,下次启动生效。 + private let launchPresentation: SceneLaunchBehavior init() { let composition = AppComposition.make() @@ -22,9 +27,11 @@ struct ShadowsocksXNG2App: App { _settingsWorkflow = StateObject(wrappedValue: composition.settingsWorkflow) _diagnosticsWorkflow = StateObject(wrappedValue: composition.diagnosticsWorkflow) _workspaceRoute = StateObject(wrappedValue: composition.workspaceRoute) + _silentLaunch = StateObject(wrappedValue: composition.silentLaunch) textClipboard = composition.textClipboard diagnosticReportExporter = composition.diagnosticReportExporter workspaceContent = composition.workspaceContent + launchPresentation = composition.silentLaunch.isEnabled ? .suppressed : .presented // GUI 事件接入内存环形缓冲(spec #21 D5,issue #34):主窗口日志查看器与 // 诊断导出的来源;wrapper 侧不注册,仍走 stderr → agent.log 收敛。 RuntimeLog.setSink(RuntimeEventStore.shared) @@ -41,19 +48,32 @@ struct ShadowsocksXNG2App: App { } .menuBarExtraStyle(.menu) - // 主 workspace 窗口(ADR 0016):SwiftUI `Window` scene。启动由 - // defaultLaunchBehavior(.presented) 自动呈现主窗口(scene 方式的启动 - // 开窗,LSUIElement 下实测生效);关窗后由状态菜单 ⑦ 经 openWindow 重开。 - // app 始终保持 accessory 形态,没有激活策略切换;关窗不退进程 - // (MenuBarExtra 持有进程)。 + // 主 workspace 窗口(ADR 0016/0017):SwiftUI `Window` scene。启动呈现由 + // defaultLaunchBehavior 决定:静默启动关闭(默认)时 presented 启动即呈现 + // (scene 方式启动开窗,LSUIElement 下实测生效),开启时 suppressed 直接 + // 进菜单栏形态。窗口开着期间 app 为 regular(Dock 图标/Cmd-Tab/默认菜单 + // 栏),关窗回 accessory 菜单栏形态——随窗激活策略由窗口 NSWindow 生命 + // 周期通知驱动(WindowActivationPolicy,scenePhase 在 macOS 跟随应用而非 + // 窗口、关窗无事件,实测不可用);关窗后由状态菜单 ⑦ 经 openWindow 重开。 + // 关窗不退进程(MenuBarExtra 持进程)。 Window("ShadowsocksX-NG2", id: WorkspaceRoute.workspaceSceneID) { workspaceContent + .modifier(WindowActivationPolicy(applying: NSAppWindowActivationApplier())) } - .defaultLaunchBehavior(.presented) + .defaultLaunchBehavior(launchPresentation) .defaultSize(width: 960, height: 640) } } +/// 随窗激活策略的真实 NSApp 落点(hermetic:单测 host 不触碰激活策略)。 +@MainActor +private final class NSAppWindowActivationApplier: WindowActivationPolicyApplying { + func apply(_ policy: NSApplication.ActivationPolicy) { + guard !ApplicationDependencies.isUnitTesting else { return } + NSApp.setActivationPolicy(policy) + } +} + /// 状态菜单的标签视图(状态栏图标)。挂载时机与菜单内容视图不同:内容视图 /// 首次点开才创建,而标签在启动时即挂载——启动 resync 只能挂在这里,挂在 /// 菜单内容上等于从不执行。主窗口的启动呈现由 Window scene 的 @@ -86,6 +106,7 @@ private struct AppComposition { let settingsWorkflow: SettingsWorkflow let diagnosticsWorkflow: DiagnosticsWorkflow let workspaceRoute: WorkspaceRoute + let silentLaunch: SilentLaunchController let textClipboard: any TextClipboard let diagnosticReportExporter: any DiagnosticReportExporter let workspaceContent: MainWindowView @@ -128,6 +149,7 @@ private struct AppComposition { runtimeFacts: controller, catalogFacts: { catalogWorkflow.diagnosticCatalogFacts }) let workspaceRoute = WorkspaceRoute() + let silentLaunch = SilentLaunchController(store: dependencies.silentLaunchStore) let workspaceContent = MainWindowView( route: workspaceRoute, workflow: catalogWorkflow, @@ -136,6 +158,7 @@ private struct AppComposition { diagnostics: diagnosticsWorkflow, settingsWorkflow: settingsWorkflow, loginController: loginController, + silentLaunch: silentLaunch, clipboard: textClipboard, diagnosticReportExporter: diagnosticReportExporter) return AppComposition( @@ -146,6 +169,7 @@ private struct AppComposition { settingsWorkflow: settingsWorkflow, diagnosticsWorkflow: diagnosticsWorkflow, workspaceRoute: workspaceRoute, + silentLaunch: silentLaunch, textClipboard: textClipboard, diagnosticReportExporter: diagnosticReportExporter, workspaceContent: workspaceContent) @@ -192,6 +216,7 @@ private struct ApplicationDependencies { let legacyImportService: LegacyImportService let launchAgent: LaunchAgentControlling let loginService: LaunchAtLoginControlling + let silentLaunchStore: SilentLaunchStore let textClipboard: any TextClipboard let diagnosticReportExporter: any DiagnosticReportExporter @@ -225,6 +250,7 @@ private struct ApplicationDependencies { catalogStore: catalogFileStore, credentials: credentials), launchAgent: SMAppLaunchAgentService(), loginService: SMAppLaunchAtLoginService(), + silentLaunchStore: SilentLaunchStore(), textClipboard: AppKitTextClipboard(), diagnosticReportExporter: AppKitDiagnosticReportExporter()) } @@ -262,6 +288,8 @@ private struct ApplicationDependencies { legacyImportService: legacyImportService, launchAgent: NoopLaunchAgentService(), loginService: NoopLaunchAtLoginService(), + silentLaunchStore: SilentLaunchStore( + fileURL: directory.appendingPathComponent("silent-launch.json")), textClipboard: InMemoryTextClipboard(), diagnosticReportExporter: InMemoryDiagnosticReportExporter()) } diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index 0ac7626d..ee0d663a 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -34,6 +34,7 @@ struct MainWindowView: View { @ObservedObject var diagnostics: DiagnosticsWorkflow @ObservedObject var settingsWorkflow: SettingsWorkflow @ObservedObject var loginController: LaunchAtLoginController + @ObservedObject var silentLaunch: SilentLaunchController let clipboard: any TextClipboard let diagnosticReportExporter: any DiagnosticReportExporter @@ -194,8 +195,11 @@ struct MainWindowView: View { workflow: workflow, onNodesRemoved: clearSelectionIfInvalidated) case .settings: - SettingsView(workflow: settingsWorkflow, loginController: loginController) - .frame(maxWidth: .infinity, maxHeight: .infinity) + SettingsView( + workflow: settingsWorkflow, loginController: loginController, + silentLaunch: silentLaunch + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) case .diagnostics: WorkspaceDiagnosticsView( diagnostics: diagnostics, diff --git a/ShadowsocksX-NG2/App/SettingsView.swift b/ShadowsocksX-NG2/App/SettingsView.swift index be5a5564..4fce8baa 100644 --- a/ShadowsocksX-NG2/App/SettingsView.swift +++ b/ShadowsocksX-NG2/App/SettingsView.swift @@ -2,10 +2,11 @@ import SwiftUI /// 设置分区(issue #33/#44,地图 #52 票 #57):常规、后台代理客户端和系统代理三张 /// 分组卡,行式呈现(主文案 + 次说明 + 行尾控件)。设置项由各自编辑器独立保存; -/// 登录项开关绑定独立控制器。 +/// 登录项与静默启动开关绑定独立控制器。 struct SettingsView: View { @ObservedObject var workflow: SettingsWorkflow @ObservedObject var loginController: LaunchAtLoginController + @ObservedObject var silentLaunch: SilentLaunchController @State private var presentedEditor: SettingsEditorSheet? var body: some View { @@ -54,6 +55,22 @@ struct SettingsView: View { } } .padding(.vertical, 4) + VStack(alignment: .leading, spacing: 4) { + Toggle( + isOn: Binding( + get: { silentLaunch.isEnabled }, + set: { silentLaunch.setEnabled($0) }) + ) { + settingCopy("静默启动", note: "启动时后台运行,不显示主窗口;下次启动生效") + } + .toggleStyle(.switch) + if let silentLaunchError = silentLaunch.errorMessage { + Text(silentLaunchError) + .font(.caption) + .foregroundStyle(.red) + } + } + .padding(.vertical, 4) } header: { sectionHeader("常规") } diff --git a/ShadowsocksX-NG2/App/SilentLaunchController.swift b/ShadowsocksX-NG2/App/SilentLaunchController.swift new file mode 100644 index 00000000..3e1fef45 --- /dev/null +++ b/ShadowsocksX-NG2/App/SilentLaunchController.swift @@ -0,0 +1,30 @@ +import SwiftUI + +/// 静默启动偏好(ADR 0017):启动时不呈现主窗口,直接以菜单栏形态后台运行。 +/// 默认关;切换即持久化,当次会话无影响,下次启动生效。代理运行时恢复与 +/// 登录项注册都不依赖它。持久化走独立的 SilentLaunchStore 而非 settings.json: +/// 代理控制器按内存快照整写该文件,旁路字段会被下一次整写覆盖。 +@MainActor +final class SilentLaunchController: ObservableObject { + @Published private(set) var isEnabled: Bool + @Published private(set) var errorMessage: String? + + private let store: SilentLaunchStore + + init(store: SilentLaunchStore) { + self.store = store + isEnabled = store.loadSilentLaunchEnabled() + } + + /// 先落盘后翻转内存态:持久化失败保持原值并点名原因,开关呈现不得与 + /// 下次启动的实际行为脱节。 + func setEnabled(_ enabled: Bool) { + do { + try store.save(silentLaunchEnabled: enabled) + isEnabled = enabled + errorMessage = nil + } catch { + errorMessage = error.presentableMessage + } + } +} diff --git a/ShadowsocksX-NG2/App/WindowActivationPolicy.swift b/ShadowsocksX-NG2/App/WindowActivationPolicy.swift new file mode 100644 index 00000000..fa3ca9e9 --- /dev/null +++ b/ShadowsocksX-NG2/App/WindowActivationPolicy.swift @@ -0,0 +1,120 @@ +import AppKit +import SwiftUI + +/// 随窗激活策略(ADR 0017):工作区窗口成为 key → regular(Dock 图标与 +/// Cmd-Tab 存在),窗口关闭 → accessory,回菜单栏形态。 +/// +/// 驱动是窗口自身的 NSWindow 生命周期通知,而非 scenePhase——真机实测 +/// (2026-09-29):macOS 上 scenePhase 跟随应用而非窗口,关窗(红点与 ⌘W +/// 同为 performClose)不产生任何 phase 事件,图标摘不掉。`willCloseNotification` +/// 对全部关闭路径必然发布;⌘H 隐藏走 orderOut、不发 willClose,形态自然保持 +/// (隐藏≠关窗),unhide 后窗口重回 key 再收敛。 +/// +/// 目标窗口经内容层锚点视图捕获(`viewDidMoveToWindow` 上交 hosting +/// NSWindow),通知按对象身份过滤——sheet/辅助窗口不是锚点宿主,天然不在 +/// 匹配集,无需 window identifier 约定。锚点只在拿到非 nil 窗口时更新:关窗 +/// 后 SwiftUI 复用同一 NSWindow(冒烟实测),重开时视 attached 状态或由 key +/// 事件收敛。 +/// 策略落点缝:协调器只产出目标策略,真实 NSApp 副作用由注入对象承担 +/// (生产=MainApp 组合根;测试=spy,全程 hermetic)。 +@MainActor +protocol WindowActivationPolicyApplying: AnyObject { + func apply(_ policy: NSApplication.ActivationPolicy) +} + +@MainActor +final class WindowActivationPolicyCoordinator: ObservableObject { + private var anchoredWindow: NSWindow? + private let applier: any WindowActivationPolicyApplying + + /// `applier` 注入以便 hermetic 单测;生产实现里单测 host 不触碰真实 NSApp。 + init(applying applier: any WindowActivationPolicyApplying) { + self.applier = applier + let center = NotificationCenter.default + center.addObserver( + self, selector: #selector(windowDidBecomeKey(_:)), + name: NSWindow.didBecomeKeyNotification, object: nil) + center.addObserver( + self, selector: #selector(windowWillClose(_:)), + name: NSWindow.willCloseNotification, object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + /// 锚点视图捕获到宿主窗口时上交(幂等)。窗口已可见才立刻收敛(覆盖不走 + /// key 事件的可见路径);静默启动预建未呈现的窗口保持 accessory,等打开 + /// 后的 key 事件。 + func anchorDidAttach(to window: NSWindow) { + guard anchoredWindow !== window else { return } + anchoredWindow = window + if window.isVisible { + applier.apply(.regular) + } + } + + @objc private func windowDidBecomeKey(_ notification: Notification) { + handle(notification, target: .regular) + } + + @objc private func windowWillClose(_ notification: Notification) { + handle(notification, target: .accessory) + } + + private func handle(_ notification: Notification, target: NSApplication.ActivationPolicy) { + guard let window = notification.object as? NSWindow, + window === anchoredWindow + else { return } + applier.apply(target) + } +} + +/// 挂在 Window scene 内容上的壳修饰器:零尺寸锚点视图捕获宿主 NSWindow, +/// 协调器据其生命周期收敛 app 激活策略。NSApp 侧 applier 由 MainApp 注入 +/// (hermetic 判定 `ApplicationDependencies` 是组合根的 private 类型)。 +struct WindowActivationPolicy: ViewModifier { + @StateObject private var coordinator: WindowActivationPolicyCoordinator + + init(applying applier: any WindowActivationPolicyApplying) { + _coordinator = StateObject(wrappedValue: WindowActivationPolicyCoordinator(applying: applier)) + } + + func body(content: Content) -> some View { + content.background(WindowActivationAnchor(onAttach: coordinator.anchorDidAttach)) + } +} + +/// 锚点视图:随内容装进宿主窗口,`viewDidMoveToWindow` 上交宿主。 +private struct WindowActivationAnchor: NSViewRepresentable { + let onAttach: (NSWindow) -> Void + + func makeNSView(context: Context) -> AnchorView { + AnchorView(onAttach: onAttach) + } + + func updateNSView(_ nsView: AnchorView, context: Context) {} + + final class AnchorView: NSView { + private let onAttach: (NSWindow) -> Void + + init(onAttach: @escaping (NSWindow) -> Void) { + self.onAttach = onAttach + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("NSView 不走 storyboard 初始化") + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + // 关窗时 SwiftUI 可能置 nil(复用窗口语义下内容也可能保留);nil 不回写, + // 已捕获引用留给 willClose 匹配,重开由重新 attach 或 key 事件覆盖。 + if let window { + onAttach(window) + } + } + } +} diff --git a/ShadowsocksX-NG2/Domain/SilentLaunchStore.swift b/ShadowsocksX-NG2/Domain/SilentLaunchStore.swift new file mode 100644 index 00000000..c929e9cc --- /dev/null +++ b/ShadowsocksX-NG2/Domain/SilentLaunchStore.swift @@ -0,0 +1,69 @@ +import Foundation + +/// 静默启动偏好持久化(ADR 0017):落盘 `~/Library/Application Support/ +/// ShadowsocksX-NG2/silent-launch.json`。GUI 呈现层独占的偏好文件,与 +/// settings.json 有意分离——代理控制器按内存快照整写 settings.json,任何 +/// 旁路字段都会被下一次整写覆盖。文件缺失、损坏或版本未知一律按安全侧 +/// 默认「不静默」(启动照常呈现主窗口)。 +struct SilentLaunchStore { + enum PersistenceError: Error, Equatable { + /// 读取或写入时的文件系统错误。 + case ioFailure(detail: String) + } + + private static let currentVersion = 1 + private static let jsonEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + }() + + let fileURL: URL + + /// 默认位置:`~/Library/Application Support/ShadowsocksX-NG2/silent-launch.json`。 + static func defaultFileURL() -> URL { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[ + 0] + return support.appendingPathComponent("ShadowsocksX-NG2/silent-launch.json") + } + + init(fileURL: URL = SilentLaunchStore.defaultFileURL()) { + self.fileURL = fileURL + } + + /// 缺失/损坏/版本未知 → `false`(安全侧:不静默)。 + func loadSilentLaunchEnabled() -> Bool { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return false } + let data: Data + do { + data = try Data(contentsOf: fileURL) + } catch { + return false + } + let payload = try? JSONDecoder().decode(SilentLaunchPayload.self, from: data) + guard let payload, payload.version == Self.currentVersion else { return false } + return payload.silentLaunchEnabled + } + + /// 整体重写并原子替换;失败时保留原文件。 + func save(silentLaunchEnabled: Bool) throws { + let data: Data + do { + data = try Self.jsonEncoder.encode( + SilentLaunchPayload(version: Self.currentVersion, silentLaunchEnabled: silentLaunchEnabled)) + } catch { + throw PersistenceError.ioFailure(detail: String(describing: error)) + } + do { + try AtomicFileWriter.write(data, to: fileURL) + } catch { + throw PersistenceError.ioFailure(detail: String(describing: error)) + } + } +} + +/// 落盘文档形态:版本号 + 偏好布尔。 +private struct SilentLaunchPayload: Codable { + var version: Int + var silentLaunchEnabled: Bool +} diff --git a/ShadowsocksX-NG2/Tests/SilentLaunchControllerTests.swift b/ShadowsocksX-NG2/Tests/SilentLaunchControllerTests.swift new file mode 100644 index 00000000..87977b1b --- /dev/null +++ b/ShadowsocksX-NG2/Tests/SilentLaunchControllerTests.swift @@ -0,0 +1,69 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +/// 静默启动控制器(ADR 0017):初始值来自持久化;翻转先落盘;持久化失败 +/// 保持原值并点名原因,开关呈现不得与下次启动的实际行为脱节。 +@MainActor +final class SilentLaunchControllerTests: XCTestCase { + private var workDir: URL! + private var store: SilentLaunchStore! + + override func setUpWithError() throws { + try super.setUpWithError() + workDir = FileManager.default.temporaryDirectory + .appendingPathComponent( + "silent-launch-controller-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) + store = SilentLaunchStore(fileURL: workDir.appendingPathComponent("silent-launch.json")) + } + + override func tearDownWithError() throws { + try FileManager.default.removeItem(at: workDir) + try super.tearDownWithError() + } + + func testInitialValueComesFromStore() throws { + try store.save(silentLaunchEnabled: true) + XCTAssertTrue(SilentLaunchController(store: store).isEnabled) + + let fresh = SilentLaunchController( + store: SilentLaunchStore( + fileURL: workDir.appendingPathComponent("absent.json"))) + XCTAssertFalse(fresh.isEnabled, "无持久化记录按出厂默认:不静默") + } + + func testEnablePersistsBeforeFlippingPublishedState() throws { + let controller = SilentLaunchController(store: store) + + controller.setEnabled(true) + + XCTAssertTrue(controller.isEnabled) + XCTAssertTrue(store.loadSilentLaunchEnabled()) + XCTAssertNil(controller.errorMessage) + } + + func testDisablePersists() throws { + try store.save(silentLaunchEnabled: true) + let controller = SilentLaunchController(store: store) + + controller.setEnabled(false) + + XCTAssertFalse(controller.isEnabled) + XCTAssertFalse(store.loadSilentLaunchEnabled()) + } + + func testPersistFailureKeepsValueAndNamesReason() throws { + // AtomicFileWriter 会自愈缺失目录(D5 基线),稳定失败路径是父路径被 + // 普通文件占据。 + let blocker = workDir.appendingPathComponent("blocker") + try Data().write(to: blocker) + let controller = SilentLaunchController( + store: SilentLaunchStore(fileURL: blocker.appendingPathComponent("silent-launch.json"))) + + controller.setEnabled(true) + + XCTAssertFalse(controller.isEnabled, "持久化失败不得翻转呈现态") + XCTAssertNotNil(controller.errorMessage) + } +} diff --git a/ShadowsocksX-NG2/Tests/SilentLaunchStoreTests.swift b/ShadowsocksX-NG2/Tests/SilentLaunchStoreTests.swift new file mode 100644 index 00000000..c847cb16 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/SilentLaunchStoreTests.swift @@ -0,0 +1,65 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +/// 静默启动偏好持久化(ADR 0017):往返无损、缺失/损坏按安全侧恢复「不静默」。 +final class SilentLaunchStoreTests: XCTestCase { + private var workDir: URL! + private var store: SilentLaunchStore! + + override func setUpWithError() throws { + try super.setUpWithError() + workDir = FileManager.default.temporaryDirectory + .appendingPathComponent("silent-launch-store-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) + store = SilentLaunchStore(fileURL: workDir.appendingPathComponent("silent-launch.json")) + } + + override func tearDownWithError() throws { + try FileManager.default.removeItem(at: workDir) + try super.tearDownWithError() + } + + // MARK: 缺失与损坏 + + func testMissingFileLoadsAsNotSilent() { + XCTAssertFalse(store.loadSilentLaunchEnabled(), "文件缺失按出厂默认:不静默") + } + + func testBrokenJSONLoadsAsNotSilentOnTheSafeSide() throws { + try "{ not json {{{".write(to: store.fileURL, atomically: true, encoding: .utf8) + + XCTAssertFalse(store.loadSilentLaunchEnabled(), "损坏按安全侧恢复:不静默") + } + + func testUnknownSchemaVersionLoadsAsNotSilent() throws { + try #"{"version": 99, "silentLaunchEnabled": true}"#.write( + to: store.fileURL, atomically: true, encoding: .utf8) + + XCTAssertFalse(store.loadSilentLaunchEnabled()) + } + + // MARK: 往返无损 + + func testSaveAndLoadRoundTripBothValues() throws { + try store.save(silentLaunchEnabled: true) + XCTAssertTrue(store.loadSilentLaunchEnabled()) + + try store.save(silentLaunchEnabled: false) + XCTAssertFalse(store.loadSilentLaunchEnabled()) + } + + func testSaveFailureWhenParentPathIsARegularFile() throws { + // AtomicFileWriter 会自愈缺失目录(D5 基线),真正稳定的失败路径是 + // 父路径被普通文件占据:目录创建必败。 + let blocker = workDir.appendingPathComponent("blocker") + try Data().write(to: blocker) + let broken = SilentLaunchStore(fileURL: blocker.appendingPathComponent("silent-launch.json")) + + XCTAssertThrowsError(try broken.save(silentLaunchEnabled: true)) { error in + guard case SilentLaunchStore.PersistenceError.ioFailure = error else { + return XCTFail("写入失败应携带 ioFailure:\(error)") + } + } + } +} diff --git a/ShadowsocksX-NG2/Tests/WindowActivationPolicyTests.swift b/ShadowsocksX-NG2/Tests/WindowActivationPolicyTests.swift new file mode 100644 index 00000000..62eea1d6 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/WindowActivationPolicyTests.swift @@ -0,0 +1,94 @@ +import AppKit +import XCTest + +@testable import ShadowsocksX_NG2 + +/// 随窗激活策略协调器(ADR 0017):窗口成为 key → regular,willClose → +/// accessory,按锚定窗口的对象身份过滤(sheet/辅助窗口不在匹配集)。通知用 +/// 手工 post 驱动、applier 用 spy 注入,全程 hermetic,不触碰真实 NSApp 策略 +/// 与 WindowServer 呈现。 +@MainActor +final class WindowActivationPolicyTests: XCTestCase { + private var spy: SpyApplier! + private var coordinator: WindowActivationPolicyCoordinator! + private var window: NSWindow! + private var foreignWindow: NSWindow! + + override func setUpWithError() throws { + try super.setUpWithError() + spy = SpyApplier() + coordinator = WindowActivationPolicyCoordinator(applying: spy) + // 离屏裸窗口仅作通知对象身份:不呈现、不抢 key,创建本身不触碰 + // WindowServer 的窗口呈现。 + window = Self.makeOffscreenWindow() + foreignWindow = Self.makeOffscreenWindow() + coordinator.anchorDidAttach(to: window) + } + + override func tearDownWithError() throws { + window = nil + foreignWindow = nil + try super.tearDownWithError() + } + + /// 策略落点 spy(ADR 0017):只记录目标策略,不触碰真实 NSApp。 + @MainActor + private final class SpyApplier: WindowActivationPolicyApplying { + private(set) var applied: [NSApplication.ActivationPolicy] = [] + + func apply(_ policy: NSApplication.ActivationPolicy) { + applied.append(policy) + } + } + + private static func makeOffscreenWindow() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 10, height: 10), + styleMask: [.titled], + backing: .buffered, + defer: false) + } + + private func post(_ name: Notification.Name, on target: NSWindow) { + NotificationCenter.default.post(name: name, object: target) + } + + // MARK: 随窗收敛 + + func testAttachToNotYetVisibleWindowAppliesNothing() { + XCTAssertTrue( + spy.applied.isEmpty, + "静默启动预建未呈现的窗口不得触发 regular:启动应停留在 accessory") + } + + func testDidBecomeKeyAppliesRegular() { + post(NSWindow.didBecomeKeyNotification, on: window) + + XCTAssertEqual(spy.applied, [.regular]) + } + + func testWillCloseAppliesAccessory() { + post(NSWindow.willCloseNotification, on: window) + + XCTAssertEqual(spy.applied, [.accessory]) + } + + // MARK: 身份过滤 + + func testForeignWindowNotificationsAreIgnored() { + post(NSWindow.didBecomeKeyNotification, on: foreignWindow) + post(NSWindow.willCloseNotification, on: foreignWindow) + + XCTAssertTrue(spy.applied.isEmpty, "非锚定窗口(sheet/辅助窗口)不得驱动策略") + } + + func testAnchorReplacementRetargetsFiltering() { + coordinator.anchorDidAttach(to: foreignWindow) + + post(NSWindow.willCloseNotification, on: window) + XCTAssertTrue(spy.applied.isEmpty, "旧窗口不再匹配") + + post(NSWindow.willCloseNotification, on: foreignWindow) + XCTAssertEqual(spy.applied, [.accessory]) + } +} diff --git a/docs/adr/0016-menu-bar-only-app-form.md b/docs/adr/0016-menu-bar-only-app-form.md index 3525e4c9..77278ee9 100644 --- a/docs/adr/0016-menu-bar-only-app-form.md +++ b/docs/adr/0016-menu-bar-only-app-form.md @@ -4,7 +4,7 @@ The GUI is a menu-bar-only app at all times: `LSUIElement` stays in effect and t ## Status -Accepted (2026-09-28). Supersedes the earlier hybrid form (2026-09-24): `LSUIElement` plus runtime accessory⇄regular activation-policy switching, launch-time auto-presentation implemented through an AppKit-owned `NSWindow`, and the window-opening intents built around it. +Accepted (2026-09-28). Superseded by ADR-0017 (2026-09-29), which replaces the constant menu-bar-only form with the adaptive app form (window-following activation policy plus an opt-out silent launch); the scene model, launch presentation, and status-menu reopening below carry over unchanged. Supersedes the earlier hybrid form (2026-09-24): `LSUIElement` plus runtime accessory⇄regular activation-policy switching, launch-time auto-presentation implemented through an AppKit-owned `NSWindow`, and the window-opening intents built around it. ## Considered options diff --git a/docs/adr/0017-adaptive-app-form.md b/docs/adr/0017-adaptive-app-form.md new file mode 100644 index 00000000..b9f2f8ff --- /dev/null +++ b/docs/adr/0017-adaptive-app-form.md @@ -0,0 +1,22 @@ +# Adaptive app form: window-following activation policy with an opt-out silent launch + +The GUI returns to runtime activation-policy switching, this time driven entirely by the workspace window's own NSWindow lifecycle: while the workspace window is open the app presents as a regular app (Dock icon, app switcher, default menu bar); when it closes the app returns to the menu-bar accessory baseline. `LSUIElement` stays in the Info.plist so launches never flash a Dock icon and the unit-test host stays clean; the policy is applied from `didBecomeKey` (→ regular) and `willClose` (→ accessory) notifications of the window captured by a zero-size anchor view embedded in the scene content, so the target window is matched by object identity and sheets or auxiliary windows can never drive the switch. `NSApp.setActivationPolicy` is the only AppKit surface. A new inline-toggled **silent launch** preference (off by default, persisted in its own file) suppresses launch presentation so launches start in the menu-bar baseline; it takes effect on the next launch. + +## Status + +Accepted (2026-09-29). Supersedes ADR-0016's constant menu-bar-only form. The scene model, launch presentation, and status-menu reopening from 0016 carry over unchanged; only the "never a Dock icon" rule is replaced. The 2026-09-24 hybrid's AppKit window adapter stays gone — no window ownership, no opening-intent adapter, no delegate. + +## Considered options + +- Drive the switch from the window scene's `scenePhase`: attempted first and rejected after real-machine testing — on macOS `scenePhase` follows the application, not the window; closing the window (close button or ⌘W alike) fires no phase event, so the Dock icon never returned to accessory. +- Keep the constant accessory form (ADR-0016): rejected — the user wants a Dock icon exactly while the workspace window is open, and the window is presented at every launch by default, so the constant-accessory rule no longer matches the product. +- Distinguish login-item launches from manual launches and present the window only for the latter: rejected — `SMAppService.mainApp` launches are indistinguishable in-process, so the distinction would need unreliable launch-reason machinery. The silent-launch preference replaces it as an explicit user choice that applies to every launch. +- Persist silent launch as a field of settings.json: rejected — the proxy controller persists settings as whole in-memory snapshots, so any parallel writer's field would be clobbered by the controller's next write; a separate one-purpose store follows the `ActivationStateFileStore` precedent and keeps the runtime controller chain from consuming a GUI presentation preference. + +## Consequences + +- While the workspace window is open the app gains a Dock icon, app-switcher and Cmd-Tab presence, and the default SwiftUI menu bar (⌘W/⌘Q come with it; no custom commands are added yet). +- With silent launch off, every launch — including login-item launches — presents the window and therefore the Dock icon until the user closes it; with silent launch on, launches start silent and proxy runtime restoration still happens at every launch. +- ⌘H hiding orders the window out without posting `willClose`, so hiding is never mistaken for closing: the current form is kept and the Dock icon stays available to unhide. +- With silent launch on, the scene's pre-built but unpresented window triggers no policy change (not visible, never key): launches start and stay accessory until the user opens the window. +- The preference is inline-toggled and persisted immediately; it affects only the next launch, and a persistence failure keeps the toggle at its previous value and names the reason.