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)
This commit is contained in:
Qiu Yuzhou
2026-09-29 15:13:30 +08:00
parent 747c6f24af
commit 8b698a0bf1
12 changed files with 533 additions and 14 deletions
+5 -4
View File
@@ -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.
+34 -6
View File
@@ -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())
}
+6 -2
View File
@@ -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,
+18 -1
View File
@@ -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("常规")
}
@@ -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
}
}
}
@@ -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)
}
}
}
}
@@ -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
}
@@ -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)
}
}
@@ -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)")
}
}
}
}
@@ -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])
}
}
+1 -1
View File
@@ -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
+22
View File
@@ -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.