diff --git a/CONTEXT.md b/CONTEXT.md index 1437b997..ae5bc7f9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -43,8 +43,8 @@ - **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. -- **Foreground form**: The app presentation while the workspace window is open: the app presents as a regular macOS app with a Dock icon and app-switcher presence. It lasts exactly as long as the workspace window is open; hiding the window does not end it. -- **Background form**: The menu-bar-only app presentation: the status menu is available and there is no Dock icon or app-switcher presence. The app enters it by closing the workspace window and leaves it by opening the workspace window; every launch starts in the foreground form by presenting the workspace window. +- **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. ## Relationships and invariants @@ -90,5 +90,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 presentation form is governed solely by the workspace window: opening it enters the foreground form, and closing it returns the app to the background form while the GUI process keeps running with the status menu available; hiding the window without closing it does not leave the foreground form. -- Every launch of the GUI presents the workspace window at startup. +- 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. diff --git a/ShadowsocksX-NG2/App/AppDelegate.swift b/ShadowsocksX-NG2/App/AppDelegate.swift deleted file mode 100644 index d3c930a5..00000000 --- a/ShadowsocksX-NG2/App/AppDelegate.swift +++ /dev/null @@ -1,74 +0,0 @@ -import AppKit - -/// 混合应用形态的 AppKit 生命周期家:LSUIElement 让进程默认纯后台 -/// (accessory),这里负责前台形态的进入与退出——任意路径使主窗口可见 -/// 时切成普通应用(regular),最后一个窗口关闭后切回纯后台。 -@MainActor -final class AppDelegate: NSObject, NSApplicationDelegate { - /// 混合形态的硬性要求:关窗不退进程,代理 GUI 仍驻留菜单栏。 - func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - false - } - - func applicationDidFinishLaunching(_ notification: Notification) { - // 启动即进入前台形态(CONTEXT.md「每次启动呈现主窗口」不变量):LSUIElement - // 应用里 SwiftUI 不做启动呈现(defaultLaunchBehavior 只有 suppressed 一档 - // 增量),而菜单 extra 内容视图首次点开才挂载、launch intent 挂不上;切到 - // regular 后交给普通应用的默认行为——启动呈现 Window scene。 - NSApp.setActivationPolicy(.regular) - NSApp.activate(ignoringOtherApps: true) - - let center = NotificationCenter.default - center.addObserver( - self, selector: #selector(windowWillClose(_:)), - name: NSWindow.willCloseNotification, object: nil) - center.addObserver( - self, selector: #selector(appOcclusionDidChange(_:)), - name: NSApplication.didChangeOcclusionStateNotification, object: nil) - } - - /// 关窗回纯后台后再经菜单 ⑦ / Dock reopen 开窗的入场:任意路径让主窗口 - /// 可见都进前台形态。只进不出:窗口被完全遮挡或 Cmd+H 也会短暂失去 - /// .visible,但那只意味着「不该退」,退场由 willClose 普查负责;多切一次 - /// .regular 是幂等的。 - @objc private func appOcclusionDidChange(_ notification: Notification) { - let hasVisibleWindow = NSApp.windows.contains { $0.isVisible && $0.canBecomeMain } - guard hasVisibleWindow, NSApp.activationPolicy() != .regular else { return } - NSApp.setActivationPolicy(.regular) - NSApp.activate(ignoringOtherApps: true) - } - - /// 前台形态下窗口被最小化时,Dock 点击只激活应用不会自动还原(SwiftUI - /// 单 Window 场景不接管 reopen),这里补上标准语义;纯后台形态没有 - /// Dock 图标,不受影响。 - func applicationShouldHandleReopen( - _ sender: NSApplication, - hasVisibleWindows flag: Bool - ) -> Bool { - guard !flag else { return true } - for window in sender.windows where window.isMiniaturized { - window.deminiaturize(nil) - } - return true - } - - /// 关最后一个可见主窗口 → 切回纯后台(与 WorkspaceWindowOpeningAdapter - /// 的开窗切 .regular 构成同一形态契约的两半)。不用 occlusion state: - /// Cmd+H 隐藏或窗口被完全遮挡也会失去 .visible,会把用户困在后台; - /// willClose 只在真关窗时发出,隐藏不算关闭。 - @objc private func windowWillClose(_ notification: Notification) { - let closingWindow = notification.object as? NSWindow - // willClose 发出时窗口仍可见,推迟一轮 runloop 再清点剩余窗口。 - DispatchQueue.main.async { - // 以「可成为主窗口」普查而不是比对 workspaceWindowID:SwiftUI 不保证 - // NSWindow 与 scene id 的稳定映射;本应用唯一的主窗口就是 workspace 窗口, - // sheet/alert 均为 panel(canBecomeMain == false),不会误判。 - let hasWindow = NSApp.windows.contains { - $0 !== closingWindow && $0.isVisible && $0.canBecomeMain - } - if !hasWindow { - NSApp.setActivationPolicy(.accessory) - } - } - } -} diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index c1aa33ba..e0b09984 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -2,7 +2,6 @@ import SwiftUI @main struct ShadowsocksXNG2App: App { - @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var proxyController: ProxyRuntimeController @StateObject private var catalogWorkflow: CatalogWorkflow @StateObject private var proxyControl: ProxyControlWorkflow @@ -12,7 +11,7 @@ struct ShadowsocksXNG2App: App { @StateObject private var workspaceRoute: WorkspaceRoute private let textClipboard: any TextClipboard private let diagnosticReportExporter: any DiagnosticReportExporter - private let windowOpening: WorkspaceWindowOpeningAdapter + private let workspaceContent: MainWindowView init() { let composition = AppComposition.make() @@ -25,7 +24,7 @@ struct ShadowsocksXNG2App: App { _workspaceRoute = StateObject(wrappedValue: composition.workspaceRoute) textClipboard = composition.textClipboard diagnosticReportExporter = composition.diagnosticReportExporter - windowOpening = composition.windowOpening + workspaceContent = composition.workspaceContent // GUI 事件接入内存环形缓冲(spec #21 D5,issue #34):主窗口日志查看器与 // 诊断导出的来源;wrapper 侧不注册,仍走 stderr → agent.log 收敛。 RuntimeLog.setSink(RuntimeEventStore.shared) @@ -36,34 +35,37 @@ struct ShadowsocksXNG2App: App { ProxyStatusMenu( control: proxyControl, catalogWorkflow: catalogWorkflow, - route: workspaceRoute, - clipboard: textClipboard, - windowOpening: windowOpening) + clipboard: textClipboard) } label: { - StatusMenuLabel( - control: proxyControl, - route: workspaceRoute, - windowOpening: windowOpening) + StatusMenuLabel(control: proxyControl) } .menuBarExtraStyle(.menu) + + // 主 workspace 窗口(ADR 0016):SwiftUI `Window` scene。启动由 + // defaultLaunchBehavior(.presented) 自动呈现主窗口(scene 方式的启动 + // 开窗,LSUIElement 下实测生效);关窗后由状态菜单 ⑦ 经 openWindow 重开。 + // app 始终保持 accessory 形态,没有激活策略切换;关窗不退进程 + // (MenuBarExtra 持有进程)。 + Window("ShadowsocksX-NG2", id: WorkspaceRoute.workspaceSceneID) { + workspaceContent + } + .defaultLaunchBehavior(.presented) + .defaultSize(width: 960, height: 640) } } /// 状态菜单的标签视图(状态栏图标)。挂载时机与菜单内容视图不同:内容视图 -/// 首次点开才创建,而标签在启动时即挂载——所以启动策略(resync + launch -/// intent,含开窗)只能挂在这里,挂在菜单内容上等于从不执行。 +/// 首次点开才创建,而标签在启动时即挂载——启动 resync 只能挂在这里,挂在 +/// 菜单内容上等于从不执行。主窗口的启动呈现由 Window scene 的 +/// defaultLaunchBehavior 负责,与本任务无时序耦合。 private struct StatusMenuLabel: View { @ObservedObject var control: ProxyControlWorkflow - @ObservedObject var route: WorkspaceRoute - let windowOpening: any WorkspaceWindowOpening var body: some View { Image(systemName: "network") .accessibilityLabel("ShadowsocksX-NG2") .task { guard !ApplicationDependencies.isUnitTesting else { return } - // 先开窗再 resync:resync 可能 await launchd 往返,不能挡在开窗前面。 - route.handle(.launch, using: windowOpening) await control.resyncOnLaunch() } } @@ -72,9 +74,9 @@ private struct StatusMenuLabel: View { /// 组合根装配:目录提交协调器与全部生产运行时适配器只在此接线一次,各 scene /// 不再重复设置提交回调;目录工作流的实现依赖经 CatalogWorkflowDependencies /// 显式装配(workflow 不自行创建生产默认实现);Legacy 导入后的 2.0 运行时 -/// 边界同为一次性注入。主窗口由 AppKit 直控(见 WorkspaceWindowOpeningAdapter -/// 文档);MainWindowView 值廉价且只捕获工作流对象,其 StateObject 状态与 -/// onAppear 副作用在视图首次进入窗口时才发生。 +/// 边界同为一次性注入。主窗口是 SwiftUI `Window` scene(MainApp body 声明), +/// 内容视图在组合根构造一次;MainWindowView 值廉价且只捕获工作流对象,其 +/// StateObject 状态与 onAppear 副作用在窗口首次打开时才发生。 @MainActor private struct AppComposition { let controller: ProxyRuntimeController @@ -86,7 +88,7 @@ private struct AppComposition { let workspaceRoute: WorkspaceRoute let textClipboard: any TextClipboard let diagnosticReportExporter: any DiagnosticReportExporter - let windowOpening: WorkspaceWindowOpeningAdapter + let workspaceContent: MainWindowView static func make() -> AppComposition { let dependencies = ApplicationDependencies.make() @@ -136,7 +138,6 @@ private struct AppComposition { loginController: loginController, clipboard: textClipboard, diagnosticReportExporter: diagnosticReportExporter) - let windowOpening = makeWindowOpening(content: workspaceContent, route: workspaceRoute) return AppComposition( controller: controller, catalogWorkflow: catalogWorkflow, @@ -147,19 +148,7 @@ private struct AppComposition { workspaceRoute: workspaceRoute, textClipboard: textClipboard, diagnosticReportExporter: diagnosticReportExporter, - windowOpening: windowOpening) - } - - /// 主窗口开窗器装配:AppKit 直控窗口(见 adapter 文档),窗口标题跟随当前 - /// 分区(首页/服务器/订阅/设置/诊断)——组合根一次性接线,@Published 订阅 - /// 立即发出当前值,开窗前即完成标题缓存。 - private static func makeWindowOpening( - content: MainWindowView, route: WorkspaceRoute - ) -> WorkspaceWindowOpeningAdapter { - let windowOpening = WorkspaceWindowOpeningAdapter( - makeContentView: { NSHostingView(rootView: content) }) - windowOpening.bindTitle(route.$destination.map(\.label).eraseToAnyPublisher()) - return windowOpening + workspaceContent: workspaceContent) } /// 目录工作流接线(issue #40/#41/#49):提交协调器与生产运行时适配器在此 diff --git a/ShadowsocksX-NG2/App/MainWindowView.swift b/ShadowsocksX-NG2/App/MainWindowView.swift index 14d3deb6..f451c8a8 100644 --- a/ShadowsocksX-NG2/App/MainWindowView.swift +++ b/ShadowsocksX-NG2/App/MainWindowView.swift @@ -19,13 +19,13 @@ private enum WorkspaceSheet: String, Identifiable { } /// 主窗口外壳(地图 #52,票 #53):NavigationSplitView 侧栏承载五项导航与 -/// 底部常驻代理状态卡;详情区按 route destination 承载各分区视图。分区大 -/// 标题由窗口标题承担(组合根把 destination label 推给开窗器,见 -/// WorkspaceWindowOpeningAdapter.bindTitle);订阅/诊断的页级动作经 .toolbar -/// 桥接进窗口工具栏右端(票 #56/#58 的槽位仅呈现位置变化),设置的表单级 -/// 提交动作在其视图内容顶部(见 SettingsView)。路由状态仍由 -/// WorkspaceRoute 持有;代理状态卡的开关、模式与摘要只来自代理控制工作流 -/// 的整体 snapshot(issue #47),与状态菜单同一口径。 +/// 底部常驻代理状态卡;详情区按 route destination 承载各分区视图。分区名即 +/// 窗口标题(navigationTitle 绑定 route destination,ADR 0016 的 scene 版 +/// 窗口原生呈现);订阅/诊断的页级动作经 .toolbar 桥接进窗口工具栏右端 +/// (票 #56/#58 的槽位仅呈现位置变化),设置的表单级提交动作在其视图内容 +/// 顶部(见 SettingsView)。路由状态仍由 WorkspaceRoute 持有;代理状态卡的 +/// 开关、模式与摘要只来自代理控制工作流的整体 snapshot(issue #47),与状态 +/// 菜单同一口径。 struct MainWindowView: View { @ObservedObject var route: WorkspaceRoute @ObservedObject var workflow: CatalogWorkflow @@ -56,6 +56,7 @@ struct MainWindowView: View { } detail: { destinationView } + .navigationTitle(route.destination.label) .frame(minWidth: 920, minHeight: 580) .toolbar { ToolbarItemGroup(placement: .primaryAction) { diff --git a/ShadowsocksX-NG2/App/ProxyStatusMenu.swift b/ShadowsocksX-NG2/App/ProxyStatusMenu.swift index c41528ff..21a4068a 100644 --- a/ShadowsocksX-NG2/App/ProxyStatusMenu.swift +++ b/ShadowsocksX-NG2/App/ProxyStatusMenu.swift @@ -14,10 +14,11 @@ struct ProxyStatusMenu: View { /// 代理控制唯一 seam(issue #47):状态摘要与代理命令的唯一来源。 @ObservedObject var control: ProxyControlWorkflow @ObservedObject var catalogWorkflow: CatalogWorkflow - @ObservedObject var route: WorkspaceRoute let clipboard: any TextClipboard - /// 开窗 seam 由组合根注入(菜单 extra 上下文的 SwiftUI openWindow 实测不可用)。 - let windowOpening: any WorkspaceWindowOpening + /// 主窗口是 SwiftUI `Window` scene(见 MainApp):启动呈现由场景的 + /// defaultLaunchBehavior 负责,状态菜单是关窗后的重开入口(ADR 0016); + /// scene id 经 WorkspaceRoute 单点引用。 + @Environment(\.openWindow) private var openWindow @StateObject private var errors = ErrorAlertPresenter() var body: some View { @@ -112,9 +113,12 @@ struct ProxyStatusMenu: View { Divider() - // ⑦ 打开主窗口 + // ⑦ 重开主窗口:启动已由 defaultLaunchBehavior(.presented) 呈现,本项 + // 服务关窗后的重开。accessory 形态下 SwiftUI 开窗不会自行抢焦点,先 + // 激活本 app 让窗口压过当前前台应用。 Button("打开主窗口…") { - route.handle(.reopen, using: windowOpening) + NSApp.activate(ignoringOtherApps: true) + openWindow(id: WorkspaceRoute.workspaceSceneID) } Divider() diff --git a/ShadowsocksX-NG2/App/WorkspaceRoute.swift b/ShadowsocksX-NG2/App/WorkspaceRoute.swift index 62c0b68a..8948b640 100644 --- a/ShadowsocksX-NG2/App/WorkspaceRoute.swift +++ b/ShadowsocksX-NG2/App/WorkspaceRoute.swift @@ -12,55 +12,22 @@ enum WorkspaceDestination: String, CaseIterable, Hashable, Identifiable, Sendabl var id: Self { self } } -/// workspace 的跨入口意图。呈现意图会要求主 workspace 可见;workspace 内部 -/// 导航只改变 destination,不重复执行窗口 effect。 -enum WorkspaceRouteIntent: Equatable, Sendable { - case launch - case navigate(destination: WorkspaceDestination) - case present(destination: WorkspaceDestination) - case reopen -} - -/// 窄的开窗 seam。实现可以连接 SwiftUI `openWindow` 或 AppKit 生命周期,route -/// module 本身不依赖任一 UI framework。 -@MainActor -protocol WorkspaceWindowOpening { - func ensureWorkspaceVisible() -} - -/// workspace route module:集中 destination、启动 policy、恢复 policy 与跨入口 -/// intent 协调。路由状态只存在于进程内,新实例始终从 home 开始。 +/// workspace route module:集中 destination 与 workspace 内部导航。路由状态 +/// 只存在于进程内,新实例始终从 home 开始。主窗口的呈现:启动由 Window +/// scene 的 defaultLaunchBehavior(.presented) 自动呈现,关窗后由状态菜单经 +/// SwiftUI `openWindow` 重开(scene id 单点定义见 workspaceSceneID);本 +/// module 不依赖任何 UI framework,也不持有开窗 effect。 @MainActor final class WorkspaceRoute: ObservableObject { + /// 主 workspace 的 SwiftUI `Window` scene id:scene 在组合根(MainApp) + /// 以此常量声明,状态菜单是关窗后的重开入口。字面量单点定义在此,防 + /// scene id 散落(架构测试锚定)。 + static let workspaceSceneID = "workspace" + @Published private(set) var destination: WorkspaceDestination = .home - private var didApplyLaunchPolicy = false - - /// workspace 内部导航只改变 route destination,不请求新的窗口呈现。 + /// workspace 内部导航只改变 route destination;开窗不由 route 驱动。 func navigate(to destination: WorkspaceDestination) { self.destination = destination } - - /// 应用一个 route intent,并在需要时请求主 workspace 可见。 - /// destination 总是在开窗 effect 之前更新,避免窗口先呈现旧位置再纠正。 - func handle( - _ intent: WorkspaceRouteIntent, - using windowOpening: any WorkspaceWindowOpening - ) { - switch intent { - case .launch: - // 启动策略(CONTEXT.md「Background form」不变量):每次启动都呈现 - // 主 workspace(前台形态开窗);Legacy 导入提示由已开窗口的 home 分区呈现。 - guard !didApplyLaunchPolicy else { return } - didApplyLaunchPolicy = true - windowOpening.ensureWorkspaceVisible() - case .navigate(let destination): - navigate(to: destination) - case .present(let destination): - self.destination = destination - windowOpening.ensureWorkspaceVisible() - case .reopen: - windowOpening.ensureWorkspaceVisible() - } - } } diff --git a/ShadowsocksX-NG2/App/WorkspaceWindowOpeningAdapter.swift b/ShadowsocksX-NG2/App/WorkspaceWindowOpeningAdapter.swift deleted file mode 100644 index a9735c15..00000000 --- a/ShadowsocksX-NG2/App/WorkspaceWindowOpeningAdapter.swift +++ /dev/null @@ -1,67 +0,0 @@ -import AppKit -import Combine -import SwiftUI - -/// 组合根持有的 workspace 开窗器:主窗口是 AppKit 拥有的 NSWindow,内容为 -/// SwiftUI 主 workspace(NSHostingView)。此前经 SwiftUI `Window` scene + -/// `openWindow` 开窗,但该环境值在 MenuBarExtra 上下文(无论标签还是内容) -/// 实测不产生窗口,SwiftUI 对 LSUIElement 应用也不做场景启动呈现——「每次 -/// 启动呈现主窗口」必须由 AppKit 直控,不依赖场景挂载时序。窗口关闭时只 -/// orderOut 不释放(isReleasedWhenClosed = false),重开即复用,workspace -/// 内部导航状态得以保留。 -@MainActor -final class WorkspaceWindowOpeningAdapter: WorkspaceWindowOpening { - static let workspaceWindowID = NSUserInterfaceItemIdentifier("main") - - private var window: NSWindow? - /// 当前分区名(组合根经 bindTitle 接线):开窗前先缓存,开窗时应用。 - private var latestTitle = "" - private var titleCancellable: AnyCancellable? - private let makeContentView: () -> NSView - - /// 内容视图由组合根惰性构造:主窗口内的 SwiftUI 状态只在首次开窗时创建。 - init(makeContentView: @escaping () -> NSView) { - self.makeContentView = makeContentView - } - - /// 窗口标题接线(组合根一次性调用):标题跟随当前 workspace destination - /// 的分区名,随 workspace 内导航实时变化;订阅生命周期由本 adapter 持有。 - func bindTitle(_ updates: AnyPublisher) { - titleCancellable = updates.sink { [weak self] title in - self?.latestTitle = title - self?.window?.title = title - } - } - - func ensureWorkspaceVisible() { - NSApp.activate(ignoringOtherApps: true) - let window = self.window ?? makeWindow() - window.makeKeyAndOrderFront(nil) - } - - private func makeWindow() -> NSWindow { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 960, height: 640), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, defer: false) - window.title = latestTitle - window.titlebarAppearsTransparent = true - window.identifier = Self.workspaceWindowID - window.isReleasedWhenClosed = false - // SwiftUI scene 窗口的 chrome 需在此手动补齐,两件缺一不可: - // ① 空 NSToolbar + .unified:NavigationSplitView 桥接把侧栏切换按钮与 - // toolbar item 装进真实工具栏,并给内容正确安全区。没有工具栏时桥接 - // 只装一半——玻璃背景画进内容区顶部而内容不缩进,分区大标题被糊成 - // 色块。 - // ② .fullSizeContentView + titlebarAppearsTransparent:侧栏通顶、窗口 - // 标题(当前分区名)呈现在内容区前缘(scene 版外观)。缺省标题栏会把 - // 标题挤在红绿灯旁的窄条里。 - let toolbar = NSToolbar(identifier: "workspace") - window.toolbar = toolbar - window.toolbarStyle = .unified - window.contentView = makeContentView() - window.center() - self.window = window - return window - } -} diff --git a/ShadowsocksX-NG2/Tests/SkeletonTests.swift b/ShadowsocksX-NG2/Tests/SkeletonTests.swift index 0a7fb320..4ca263e7 100644 --- a/ShadowsocksX-NG2/Tests/SkeletonTests.swift +++ b/ShadowsocksX-NG2/Tests/SkeletonTests.swift @@ -7,12 +7,6 @@ final class SkeletonTests: XCTestCase { // 以下两个 Bundle.main 断言依赖测试包以 app 为 TEST_HOST 注入(target dependency); // 若移除该依赖,Bundle.main 不再是 app bundle,断言会失效。 - func testMenuBarAppDoesNotTerminateAfterLastWindowClosed() { - let delegate = AppDelegate() - let shouldTerminate = delegate.applicationShouldTerminateAfterLastWindowClosed(.shared) - XCTAssertFalse(shouldTerminate) - } - func testAppBundleIDIsDistinctFromLegacy() { XCTAssertEqual(Bundle.main.bundleIdentifier, "com.qiuyuzhou.ShadowsocksX-NG2") XCTAssertNotEqual(Bundle.main.bundleIdentifier, "com.qiuyuzhou.ShadowsocksX-NG") @@ -59,11 +53,10 @@ final class CatalogWorkflowArchitectureTests: XCTestCase { private static let testTargetRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent().deletingLastPathComponent() - /// 豁免文件:组合根(生产 adapter 唯一装配点)、应用生命周期与运行时/ - /// 系统适配器(合法接触原始目录与凭据类型)。 + /// 豁免文件:组合根(生产 adapter 唯一装配点)与运行时/系统适配器(合法 + /// 接触原始目录与凭据类型)。 private static let exemptedFiles: Set = [ "MainApp.swift", - "AppDelegate.swift", "CatalogCommitCoordinator.swift", "ProxyRuntimeController.swift", // 控制器按命令面/设置与目录同步/系统代理门禁/事实投影分文件(同属运行时适配器)。 diff --git a/ShadowsocksX-NG2/Tests/WorkspaceRouteTests.swift b/ShadowsocksX-NG2/Tests/WorkspaceRouteTests.swift index 6607ca06..f989b33a 100644 --- a/ShadowsocksX-NG2/Tests/WorkspaceRouteTests.swift +++ b/ShadowsocksX-NG2/Tests/WorkspaceRouteTests.swift @@ -5,17 +5,6 @@ import XCTest @MainActor final class WorkspaceRouteTests: XCTestCase { - private final class RecordingWindowOpening: WorkspaceWindowOpening { - var openCount = 0 - var destinationAtOpen: WorkspaceDestination? - var onOpen: (() -> Void)? - - func ensureWorkspaceVisible() { - openCount += 1 - onOpen?() - } - } - func testFreshRouteStartsAtHomeWithoutPersistedState() { XCTAssertEqual(WorkspaceRoute().destination, .home) } @@ -26,95 +15,26 @@ final class WorkspaceRouteTests: XCTestCase { [.home, .servers, .subscriptions, .settings, .diagnostics]) } - func testLaunchPresentsWorkspaceAtHome() { + func testNavigateChangesDestinationOnly() { let route = WorkspaceRoute() - let opening = RecordingWindowOpening() - opening.onOpen = { opening.destinationAtOpen = route.destination } - route.handle(.launch, using: opening) - - XCTAssertEqual(route.destination, .home) - XCTAssertEqual(opening.openCount, 1) - XCTAssertEqual(opening.destinationAtOpen, .home) - } - - func testLaunchPolicyIsAppliedOnlyOnce() { - let route = WorkspaceRoute() - let opening = RecordingWindowOpening() - - route.handle(.launch, using: opening) - route.handle(.launch, using: opening) - - XCTAssertEqual(opening.openCount, 1) - } - - func testDelayedLaunchPolicyDoesNotOverwriteExplicitDestination() { - let route = WorkspaceRoute() - let opening = RecordingWindowOpening() - opening.onOpen = { opening.destinationAtOpen = route.destination } - - route.handle(.present(destination: .settings), using: opening) - route.handle(.launch, using: opening) - - XCTAssertEqual(route.destination, .settings) - XCTAssertEqual(opening.openCount, 2) - XCTAssertEqual(opening.destinationAtOpen, .settings) - } - - func testExplicitSettingsPresentationSelectsBeforeOpeningWorkspace() { - let route = WorkspaceRoute() - let opening = RecordingWindowOpening() - opening.onOpen = { opening.destinationAtOpen = route.destination } - - route.handle(.present(destination: .settings), using: opening) - - XCTAssertEqual(route.destination, .settings) - XCTAssertEqual(opening.openCount, 1) - XCTAssertEqual(opening.destinationAtOpen, .settings) - } - - func testInWorkspaceNavigationChangesDestinationWithoutOpeningWindow() { - let route = WorkspaceRoute() - let opening = RecordingWindowOpening() - - route.handle(.navigate(destination: .servers), using: opening) + route.navigate(to: .servers) XCTAssertEqual(route.destination, .servers) - XCTAssertEqual(opening.openCount, 0) } - func testExplicitHomePresentationSelectsHomeBeforeOpeningWorkspace() { - let route = WorkspaceRoute() - let opening = RecordingWindowOpening() - opening.onOpen = { opening.destinationAtOpen = route.destination } - route.navigate(to: .diagnostics) - - route.handle(.present(destination: .home), using: opening) - - XCTAssertEqual(route.destination, .home) - XCTAssertEqual(opening.openCount, 1) - XCTAssertEqual(opening.destinationAtOpen, .home) - } - - func testPassiveReopenRetainsCurrentDestination() { - let route = WorkspaceRoute() - let opening = RecordingWindowOpening() - opening.onOpen = { opening.destinationAtOpen = route.destination } - route.handle(.navigate(destination: .diagnostics), using: opening) - - route.handle(.reopen, using: opening) - - XCTAssertEqual(route.destination, .diagnostics) - XCTAssertEqual(opening.openCount, 1) - XCTAssertEqual(opening.destinationAtOpen, .diagnostics) - } - - func testSceneOpeningEffectsRemainInCompositionRootOrFocusedAdapter() throws { + /// 开窗落点纪律(ADR 0016,scene 方式):主窗口是 SwiftUI `Window` scene, + /// `openWindow` 调用只允许出现在状态菜单(关窗后的重开入口;启动呈现由 + /// 场景 defaultLaunchBehavior 负责);scene id 字面量只允许定义在 route + /// (单点词汇),组合根以常量声明 scene。防视图任意开窗与 scene id 散落 + /// 回归 AppKit 直控。 + func testWindowOpeningStaysInSanctionedSurfaces() throws { let appDirectory = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() .appendingPathComponent("App", isDirectory: true) - let allowedFiles = Set(["MainApp.swift", "WorkspaceWindowOpeningAdapter.swift"]) + let openWindowAllowed = Set(["ProxyStatusMenu.swift"]) + let sceneIDLiteralAllowed = Set(["WorkspaceRoute.swift"]) let fileManager = FileManager.default let urls = try XCTUnwrap( fileManager.enumerator( @@ -124,19 +44,24 @@ final class WorkspaceRouteTests: XCTestCase { )?.compactMap { $0 as? URL } .filter { $0.pathExtension == "swift" }) - let violations = urls.compactMap { url -> String? in - guard !allowedFiles.contains(url.lastPathComponent), - let source = try? String(contentsOf: url, encoding: .utf8) - else { return nil } - guard - source.contains("openWindow(id:") || source.contains("id: \"main\"") - || source.contains("id: \"settings\"") - else { return nil } - return url.lastPathComponent + var openWindowViolations: [String] = [] + var sceneIDViolations: [String] = [] + for url in urls { + guard let source = try? String(contentsOf: url, encoding: .utf8) else { continue } + let name = url.lastPathComponent + if !openWindowAllowed.contains(name), source.contains("openWindow(") { + openWindowViolations.append(name) + } + if !sceneIDLiteralAllowed.contains(name), source.contains(#""workspace""#) { + sceneIDViolations.append(name) + } } XCTAssertTrue( - violations.isEmpty, - "scene ID 与开窗 effect 只能位于组合根或专用 adapter:\(violations)") + openWindowViolations.isEmpty, + "openWindow 调用只允许在状态菜单:\(openWindowViolations)") + XCTAssertTrue( + sceneIDViolations.isEmpty, + "scene id 字面量只允许定义在 WorkspaceRoute:\(sceneIDViolations)") } } diff --git a/docs/adr/0016-menu-bar-only-app-form.md b/docs/adr/0016-menu-bar-only-app-form.md new file mode 100644 index 00000000..3525e4c9 --- /dev/null +++ b/docs/adr/0016-menu-bar-only-app-form.md @@ -0,0 +1,21 @@ +# Menu-bar-only app form; workspace window as a scene + +The GUI is a menu-bar-only app at all times: `LSUIElement` stays in effect and the app never switches its activation policy at runtime. The main workspace window is a SwiftUI `Window` scene presented automatically at every launch (`defaultLaunchBehavior(.presented)`); the status menu's "open main window" item reopens it after it is closed. Closing the window never terminates the GUI process — the status menu (and the launchd-hosted proxy agent with it) stays available. + +## 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. + +## Considered options + +- Keep the hybrid form (activation-policy switching plus AppKit window ownership): rejected because the AppKit window adapter existed only to serve the policy dance and the launch intents; the plain scene model provides both launch presentation and reopening without it. +- Menu-bar-only with no launch presentation (window opens only from the status menu): considered as an intermediate step on 2026-09-28 and reversed the same day — scene-based launch presentation was verified to work under `LSUIElement` (`defaultLaunchBehavior(.presented)`), so the launch-presentation behavior returns without bringing the AppKit adapter back. +- Keep an AppKit-direct window adapter solely to reuse a retained window across close/open: rejected; the scene window survives close by being re-shown with its content state intact, so the adapter's remaining value was the activation-policy switching this decision removes. + +## Consequences + +- The app never gains a Dock icon or app-switcher presence, including while the workspace window is open; launch presents the window without a Dock icon appearing. +- Reopening from the status menu activates the app before `openWindow`, so the window lands above the frontmost app. +- Keyboard shortcuts that rely on owning the menu bar (for example ⌘W or Edit-menu shortcuts) are unavailable while the workspace window is frontmost; the close button and mouse interaction are unaffected. If this proves unacceptable, the fix is a product decision to re-introduce a foreground form, not a partial AppKit adapter. +- The launch intent, reopen intent, presentation intents, and the `WorkspaceWindowOpening` seam are removed; `WorkspaceRoute` is navigation state only, and the scene id is defined once there. +- Window title follows the workspace destination via `navigationTitle` instead of an AppKit title binding.