From cb4ad5a87dd86ab10498c10802a4e413d49e80a4 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Thu, 24 Sep 2026 22:53:11 +0800 Subject: [PATCH] feat(ng2): hybrid app form with foreground workspace window --- CONTEXT.md | 4 ++ ShadowsocksX-NG2/App/AppDelegate.swift | 45 ++++++++++++++++++- ShadowsocksX-NG2/App/LaunchContext.swift | 20 +++++++++ .../App/LoginAtLoginService.swift | 12 ++++- ShadowsocksX-NG2/App/MainApp.swift | 8 +++- ShadowsocksX-NG2/App/ProxyStatusMenu.swift | 2 +- ShadowsocksX-NG2/App/WorkspaceRoute.swift | 14 ++++-- .../App/WorkspaceWindowOpeningAdapter.swift | 4 ++ .../Tests/LoginAtLoginServiceTests.swift | 28 ++++++++++++ .../Tests/WorkspaceRouteTests.swift | 45 ++++++++++++------- 10 files changed, 157 insertions(+), 25 deletions(-) create mode 100644 ShadowsocksX-NG2/App/LaunchContext.swift diff --git a/CONTEXT.md b/CONTEXT.md index 3ab88b5e..0a2dc192 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -34,6 +34,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 default menu-bar-only app presentation: the status menu is available and there is no Dock icon or app-switcher presence. A login-item launch always starts in the background form; every other launch starts in the foreground form by presenting the workspace window. ## Relationships and invariants @@ -74,3 +76,5 @@ - Proxy modes are mutually exclusive: local PAC writes the runtime PAC URL and global writes only the local SOCKS proxy. System proxy writes are allowed only after endpoint health; 2.0 stores the complete per-service Proxies dictionaries it owns and restores them only while the applied values still match, otherwise it reports an ownership conflict. - The proxy mode selector's choice persists with the settings snapshot and survives GUI restarts; a persistence failure keeps the previous mode in force and names the reason. Local PAC and global are always available, and user-provided remote PAC URLs are not accepted as proxy settings. Resetting preferences returns the mode to local PAC. - 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. +- A login-item launch never presents the workspace window; every other launch presents it at startup. diff --git a/ShadowsocksX-NG2/App/AppDelegate.swift b/ShadowsocksX-NG2/App/AppDelegate.swift index 64e78811..387c586d 100644 --- a/ShadowsocksX-NG2/App/AppDelegate.swift +++ b/ShadowsocksX-NG2/App/AppDelegate.swift @@ -1,8 +1,51 @@ import AppKit +/// 混合应用形态的 AppKit 生命周期家:LSUIElement 让进程默认纯后台 +/// (accessory),主 workspace 可见期间由开窗 adapter 切成普通应用 +/// (regular);这里负责最后一个窗口关闭后切回纯后台。 final class AppDelegate: NSObject, NSApplicationDelegate { - /// LSUIElement 菜单栏形态的硬性要求:关闭最后一个窗口后进程必须保留。 + /// 混合形态的硬性要求:关窗不退进程,代理 GUI 仍驻留菜单栏。 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } + + func applicationDidFinishLaunching(_ notification: Notification) { + NotificationCenter.default.addObserver( + self, selector: #selector(windowWillClose(_:)), + name: NSWindow.willCloseNotification, object: nil) + } + + /// 前台形态下窗口被最小化时,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/LaunchContext.swift b/ShadowsocksX-NG2/App/LaunchContext.swift new file mode 100644 index 00000000..43c48550 --- /dev/null +++ b/ShadowsocksX-NG2/App/LaunchContext.swift @@ -0,0 +1,20 @@ +import Foundation + +/// 进程启动来源判定(启动策略的输入,CONTEXT.md「Background form」不变量)。 +/// macOS 没有「本次因登录项而启动」的系统 API(SMAppService 只暴露注册态, +/// 不暴露启动原因),故采用社区标准启发式:登录项已启用且距开机很近。 +/// 误判边界:登录项启用时,开机后窗口期内手动启动也会被按登录启动对待 +/// (静默不开主窗口);主窗口从状态菜单随时可开,损失可接受。 +enum LaunchContext { + /// 判定窗口期:须覆盖慢启动(FileVault 解密、慢外设枚举),同时尽量 + /// 压缩手动启动的误判面。 + static let loginLaunchWindow: TimeInterval = 120 + + static func isLoginItemLaunch( + loginItemEnabled: Bool, + systemUptime: TimeInterval + ) -> Bool { + guard loginItemEnabled else { return false } + return systemUptime < loginLaunchWindow + } +} diff --git a/ShadowsocksX-NG2/App/LoginAtLoginService.swift b/ShadowsocksX-NG2/App/LoginAtLoginService.swift index 9f2becad..b29bc32d 100644 --- a/ShadowsocksX-NG2/App/LoginAtLoginService.swift +++ b/ShadowsocksX-NG2/App/LoginAtLoginService.swift @@ -15,6 +15,14 @@ protocol LaunchAtLoginControlling { func unregister() throws } +extension LoginItemStatus { + /// 已注册或已请求注册待批准都算开启——系统 API 状态的唯一口径, + /// LaunchAtLoginController.isEnabled 与启动来源判定共用。 + var countsAsEnabled: Bool { + self == .registered || self == .requiresApproval + } +} + struct SMAppLaunchAtLoginService: LaunchAtLoginControlling { private let service = SMAppService.mainApp @@ -58,9 +66,9 @@ final class LaunchAtLoginController: ObservableObject { status = service.status } - /// 已注册或已请求注册待批准都算开启——两者都只来自系统 API 的状态。 + /// 已注册或已请求注册待批准都算开启。 var isEnabled: Bool { - status == .registered || status == .requiresApproval + status.countsAsEnabled } var requiresApproval: Bool { status == .requiresApproval } diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index 2a784232..60bf99e8 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -70,7 +70,13 @@ struct ShadowsocksXNG2App: App { wrappedValue: DiagnosticsWorkflow( runtimeFacts: controller, catalogFacts: { catalogWorkflow.diagnosticCatalogFacts })) - _workspaceRoute = StateObject(wrappedValue: WorkspaceRoute()) + // 启动策略(CONTEXT.md「Background form」不变量):登录项拉起静默, + // 其余启动开主窗口(前台形态);Legacy 导入提示由已开窗口的 home 分区呈现。 + let opensWorkspaceAtLaunch = !LaunchContext.isLoginItemLaunch( + loginItemEnabled: dependencies.loginService.status.countsAsEnabled, + systemUptime: ProcessInfo.processInfo.systemUptime) + _workspaceRoute = StateObject( + wrappedValue: WorkspaceRoute(opensWorkspaceAtLaunch: opensWorkspaceAtLaunch)) // GUI 事件接入内存环形缓冲(spec #21 D5,issue #34):主窗口日志查看器与 // 诊断导出的来源;wrapper 侧不注册,仍走 stderr → agent.log 收敛。 RuntimeLog.setSink(RuntimeEventStore.shared) diff --git a/ShadowsocksX-NG2/App/ProxyStatusMenu.swift b/ShadowsocksX-NG2/App/ProxyStatusMenu.swift index 5a5e05e7..44613da0 100644 --- a/ShadowsocksX-NG2/App/ProxyStatusMenu.swift +++ b/ShadowsocksX-NG2/App/ProxyStatusMenu.swift @@ -24,7 +24,7 @@ struct ProxyStatusMenu: View { guard !isUnitTesting else { return } await control.resyncOnLaunch() route.handle( - .launch(legacyImportOffer: catalogWorkflow.legacyImportState.shouldOffer), + .launch, using: WorkspaceWindowOpeningAdapter(openWindow: openWindow)) } .alert( diff --git a/ShadowsocksX-NG2/App/WorkspaceRoute.swift b/ShadowsocksX-NG2/App/WorkspaceRoute.swift index 77a404d1..fbf85e39 100644 --- a/ShadowsocksX-NG2/App/WorkspaceRoute.swift +++ b/ShadowsocksX-NG2/App/WorkspaceRoute.swift @@ -15,7 +15,7 @@ enum WorkspaceDestination: String, CaseIterable, Hashable, Identifiable, Sendabl /// workspace 的跨入口意图。呈现意图会要求主 workspace 可见;workspace 内部 /// 导航只改变 destination,不重复执行窗口 effect。 enum WorkspaceRouteIntent: Equatable, Sendable { - case launch(legacyImportOffer: Bool) + case launch case navigate(destination: WorkspaceDestination) case present(destination: WorkspaceDestination) case reopen @@ -34,8 +34,16 @@ protocol WorkspaceWindowOpening { final class WorkspaceRoute: ObservableObject { @Published private(set) var destination: WorkspaceDestination = .home + /// 启动是否呈现主 workspace:登录项拉起为 false(静默驻留纯后台形态), + /// 其余启动为 true(前台形态开窗)。判定来源见 LaunchContext。 + private let opensWorkspaceAtLaunch: Bool + private var didApplyLaunchPolicy = false + init(opensWorkspaceAtLaunch: Bool) { + self.opensWorkspaceAtLaunch = opensWorkspaceAtLaunch + } + /// workspace 内部导航只改变 route destination,不请求新的窗口呈现。 func navigate(to destination: WorkspaceDestination) { self.destination = destination @@ -48,10 +56,10 @@ final class WorkspaceRoute: ObservableObject { using windowOpening: any WorkspaceWindowOpening ) { switch intent { - case .launch(let legacyImportOffer): + case .launch: guard !didApplyLaunchPolicy else { return } didApplyLaunchPolicy = true - if legacyImportOffer { + if opensWorkspaceAtLaunch { windowOpening.ensureWorkspaceVisible() } case .navigate(let destination): diff --git a/ShadowsocksX-NG2/App/WorkspaceWindowOpeningAdapter.swift b/ShadowsocksX-NG2/App/WorkspaceWindowOpeningAdapter.swift index bd3e5335..f96b1dde 100644 --- a/ShadowsocksX-NG2/App/WorkspaceWindowOpeningAdapter.swift +++ b/ShadowsocksX-NG2/App/WorkspaceWindowOpeningAdapter.swift @@ -14,6 +14,10 @@ struct WorkspaceWindowOpeningAdapter: WorkspaceWindowOpening { } func ensureWorkspaceVisible() { + // 混合形态契约(CONTEXT.md「Foreground form」不变量):主 workspace 可见 + // 期间以普通应用呈现,Dock 图标随窗口出现;最后一个窗口关闭后由 + // AppDelegate 切回纯后台形态。LSUIElement 保证启动即静默。 + NSApp.setActivationPolicy(.regular) NSApp.activate(ignoringOtherApps: true) openWindow(id: Self.workspaceWindowID) } diff --git a/ShadowsocksX-NG2/Tests/LoginAtLoginServiceTests.swift b/ShadowsocksX-NG2/Tests/LoginAtLoginServiceTests.swift index 8814abcd..e757ff81 100644 --- a/ShadowsocksX-NG2/Tests/LoginAtLoginServiceTests.swift +++ b/ShadowsocksX-NG2/Tests/LoginAtLoginServiceTests.swift @@ -60,6 +60,34 @@ final class LaunchAtLoginServiceTests: XCTestCase { XCTAssertEqual(controller.errorMessage, AppPresentation.unknownError) } + // MARK: 登录项启动判定(LaunchContext,启动策略输入) + + func testLoginLaunchRequiresEnabledLoginItem() { + XCTAssertFalse( + LaunchContext.isLoginItemLaunch(loginItemEnabled: false, systemUptime: 5), + "登录项未启用时,开机后立即手动启动也不该被当成登录启动") + XCTAssertTrue(LaunchContext.isLoginItemLaunch(loginItemEnabled: true, systemUptime: 5)) + } + + func testApprovalPendingAndRegisteredCountAsEnabledLoginItem() { + XCTAssertTrue(LoginItemStatus.registered.countsAsEnabled) + XCTAssertTrue(LoginItemStatus.requiresApproval.countsAsEnabled) + XCTAssertFalse(LoginItemStatus.notRegistered.countsAsEnabled) + XCTAssertFalse(LoginItemStatus.notFound.countsAsEnabled) + } + + func testUptimeBeyondBootWindowIsManualLaunch() { + XCTAssertFalse( + LaunchContext.isLoginItemLaunch( + loginItemEnabled: true, + systemUptime: LaunchContext.loginLaunchWindow), + "窗口期右边界(开区间)之外视为手动启动") + XCTAssertFalse( + LaunchContext.isLoginItemLaunch( + loginItemEnabled: true, + systemUptime: LaunchContext.loginLaunchWindow + 1)) + } + private enum FakeError: Error, CustomStringConvertible { case system diff --git a/ShadowsocksX-NG2/Tests/WorkspaceRouteTests.swift b/ShadowsocksX-NG2/Tests/WorkspaceRouteTests.swift index 9075b02f..eec634ec 100644 --- a/ShadowsocksX-NG2/Tests/WorkspaceRouteTests.swift +++ b/ShadowsocksX-NG2/Tests/WorkspaceRouteTests.swift @@ -17,7 +17,7 @@ final class WorkspaceRouteTests: XCTestCase { } func testFreshRouteStartsAtHomeWithoutPersistedState() { - XCTAssertEqual(WorkspaceRoute().destination, .home) + XCTAssertEqual(WorkspaceRoute(opensWorkspaceAtLaunch: true).destination, .home) } func testDestinationVocabularyIsFiniteAndStable() { @@ -26,45 +26,56 @@ final class WorkspaceRouteTests: XCTestCase { [.home, .servers, .subscriptions, .settings, .diagnostics]) } - func testFirstLaunchWithLegacyOfferPresentsHome() { - let route = WorkspaceRoute() + func testRegularLaunchPresentsWorkspaceAtHome() { + let route = WorkspaceRoute(opensWorkspaceAtLaunch: true) let opening = RecordingWindowOpening() opening.onOpen = { opening.destinationAtOpen = route.destination } - route.handle(.launch(legacyImportOffer: true), using: opening) + route.handle(.launch, using: opening) XCTAssertEqual(route.destination, .home) XCTAssertEqual(opening.openCount, 1) XCTAssertEqual(opening.destinationAtOpen, .home) } - func testFirstLaunchWithoutLegacyOfferDoesNotPresentWorkspace() { - let route = WorkspaceRoute() + func testLoginItemLaunchStaysSilent() { + let route = WorkspaceRoute(opensWorkspaceAtLaunch: false) let opening = RecordingWindowOpening() - route.handle(.launch(legacyImportOffer: false), using: opening) + route.handle(.launch, using: opening) XCTAssertEqual(route.destination, .home) XCTAssertEqual(opening.openCount, 0) } - func testLaunchPolicyIsAppliedOnlyOnce() { - let route = WorkspaceRoute() + func testSilentLaunchStillAllowsExplicitPresentation() { + let route = WorkspaceRoute(opensWorkspaceAtLaunch: false) let opening = RecordingWindowOpening() - route.handle(.launch(legacyImportOffer: true), using: opening) - route.handle(.launch(legacyImportOffer: true), using: opening) + route.handle(.launch, using: opening) + route.handle(.present(destination: .settings), using: opening) + + XCTAssertEqual(route.destination, .settings) + XCTAssertEqual(opening.openCount, 1) + } + + func testLaunchPolicyIsAppliedOnlyOnce() { + let route = WorkspaceRoute(opensWorkspaceAtLaunch: true) + let opening = RecordingWindowOpening() + + route.handle(.launch, using: opening) + route.handle(.launch, using: opening) XCTAssertEqual(opening.openCount, 1) } func testDelayedLaunchPolicyDoesNotOverwriteExplicitDestination() { - let route = WorkspaceRoute() + let route = WorkspaceRoute(opensWorkspaceAtLaunch: true) let opening = RecordingWindowOpening() opening.onOpen = { opening.destinationAtOpen = route.destination } route.handle(.present(destination: .settings), using: opening) - route.handle(.launch(legacyImportOffer: true), using: opening) + route.handle(.launch, using: opening) XCTAssertEqual(route.destination, .settings) XCTAssertEqual(opening.openCount, 2) @@ -72,7 +83,7 @@ final class WorkspaceRouteTests: XCTestCase { } func testExplicitSettingsPresentationSelectsBeforeOpeningWorkspace() { - let route = WorkspaceRoute() + let route = WorkspaceRoute(opensWorkspaceAtLaunch: true) let opening = RecordingWindowOpening() opening.onOpen = { opening.destinationAtOpen = route.destination } @@ -84,7 +95,7 @@ final class WorkspaceRouteTests: XCTestCase { } func testInWorkspaceNavigationChangesDestinationWithoutOpeningWindow() { - let route = WorkspaceRoute() + let route = WorkspaceRoute(opensWorkspaceAtLaunch: true) let opening = RecordingWindowOpening() route.handle(.navigate(destination: .servers), using: opening) @@ -94,7 +105,7 @@ final class WorkspaceRouteTests: XCTestCase { } func testExplicitHomePresentationSelectsHomeBeforeOpeningWorkspace() { - let route = WorkspaceRoute() + let route = WorkspaceRoute(opensWorkspaceAtLaunch: true) let opening = RecordingWindowOpening() opening.onOpen = { opening.destinationAtOpen = route.destination } route.navigate(to: .diagnostics) @@ -107,7 +118,7 @@ final class WorkspaceRouteTests: XCTestCase { } func testPassiveReopenRetainsCurrentDestination() { - let route = WorkspaceRoute() + let route = WorkspaceRoute(opensWorkspaceAtLaunch: true) let opening = RecordingWindowOpening() opening.onOpen = { opening.destinationAtOpen = route.destination } route.handle(.navigate(destination: .diagnostics), using: opening)