mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-02 16:53:45 +00:00
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:
+5
-4
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user