diff --git a/CONTEXT.md b/CONTEXT.md index 0a2dc192..305be430 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -35,7 +35,7 @@ - **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. +- **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. ## Relationships and invariants @@ -77,4 +77,4 @@ - 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. +- Every launch of the GUI presents the workspace window at startup. diff --git a/ShadowsocksX-NG2/App/LaunchContext.swift b/ShadowsocksX-NG2/App/LaunchContext.swift deleted file mode 100644 index 43c48550..00000000 --- a/ShadowsocksX-NG2/App/LaunchContext.swift +++ /dev/null @@ -1,20 +0,0 @@ -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 b29bc32d..9f2becad 100644 --- a/ShadowsocksX-NG2/App/LoginAtLoginService.swift +++ b/ShadowsocksX-NG2/App/LoginAtLoginService.swift @@ -15,14 +15,6 @@ 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 @@ -66,9 +58,9 @@ final class LaunchAtLoginController: ObservableObject { status = service.status } - /// 已注册或已请求注册待批准都算开启。 + /// 已注册或已请求注册待批准都算开启——两者都只来自系统 API 的状态。 var isEnabled: Bool { - status.countsAsEnabled + status == .registered || status == .requiresApproval } var requiresApproval: Bool { status == .requiresApproval } diff --git a/ShadowsocksX-NG2/App/MainApp.swift b/ShadowsocksX-NG2/App/MainApp.swift index 60bf99e8..2a784232 100644 --- a/ShadowsocksX-NG2/App/MainApp.swift +++ b/ShadowsocksX-NG2/App/MainApp.swift @@ -70,13 +70,7 @@ struct ShadowsocksXNG2App: App { wrappedValue: DiagnosticsWorkflow( runtimeFacts: controller, catalogFacts: { catalogWorkflow.diagnosticCatalogFacts })) - // 启动策略(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)) + _workspaceRoute = StateObject(wrappedValue: WorkspaceRoute()) // GUI 事件接入内存环形缓冲(spec #21 D5,issue #34):主窗口日志查看器与 // 诊断导出的来源;wrapper 侧不注册,仍走 stderr → agent.log 收敛。 RuntimeLog.setSink(RuntimeEventStore.shared) diff --git a/ShadowsocksX-NG2/App/WorkspaceRoute.swift b/ShadowsocksX-NG2/App/WorkspaceRoute.swift index fbf85e39..62c0b68a 100644 --- a/ShadowsocksX-NG2/App/WorkspaceRoute.swift +++ b/ShadowsocksX-NG2/App/WorkspaceRoute.swift @@ -34,16 +34,8 @@ 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 @@ -57,11 +49,11 @@ final class WorkspaceRoute: ObservableObject { ) { switch intent { case .launch: + // 启动策略(CONTEXT.md「Background form」不变量):每次启动都呈现 + // 主 workspace(前台形态开窗);Legacy 导入提示由已开窗口的 home 分区呈现。 guard !didApplyLaunchPolicy else { return } didApplyLaunchPolicy = true - if opensWorkspaceAtLaunch { - windowOpening.ensureWorkspaceVisible() - } + windowOpening.ensureWorkspaceVisible() case .navigate(let destination): navigate(to: destination) case .present(let destination): diff --git a/ShadowsocksX-NG2/Tests/LoginAtLoginServiceTests.swift b/ShadowsocksX-NG2/Tests/LoginAtLoginServiceTests.swift index e757ff81..8814abcd 100644 --- a/ShadowsocksX-NG2/Tests/LoginAtLoginServiceTests.swift +++ b/ShadowsocksX-NG2/Tests/LoginAtLoginServiceTests.swift @@ -60,34 +60,6 @@ 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 eec634ec..6607ca06 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(opensWorkspaceAtLaunch: true).destination, .home) + XCTAssertEqual(WorkspaceRoute().destination, .home) } func testDestinationVocabularyIsFiniteAndStable() { @@ -26,8 +26,8 @@ final class WorkspaceRouteTests: XCTestCase { [.home, .servers, .subscriptions, .settings, .diagnostics]) } - func testRegularLaunchPresentsWorkspaceAtHome() { - let route = WorkspaceRoute(opensWorkspaceAtLaunch: true) + func testLaunchPresentsWorkspaceAtHome() { + let route = WorkspaceRoute() let opening = RecordingWindowOpening() opening.onOpen = { opening.destinationAtOpen = route.destination } @@ -38,29 +38,8 @@ final class WorkspaceRouteTests: XCTestCase { XCTAssertEqual(opening.destinationAtOpen, .home) } - func testLoginItemLaunchStaysSilent() { - let route = WorkspaceRoute(opensWorkspaceAtLaunch: false) - let opening = RecordingWindowOpening() - - route.handle(.launch, using: opening) - - XCTAssertEqual(route.destination, .home) - XCTAssertEqual(opening.openCount, 0) - } - - func testSilentLaunchStillAllowsExplicitPresentation() { - let route = WorkspaceRoute(opensWorkspaceAtLaunch: false) - let opening = RecordingWindowOpening() - - 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 route = WorkspaceRoute() let opening = RecordingWindowOpening() route.handle(.launch, using: opening) @@ -70,7 +49,7 @@ final class WorkspaceRouteTests: XCTestCase { } func testDelayedLaunchPolicyDoesNotOverwriteExplicitDestination() { - let route = WorkspaceRoute(opensWorkspaceAtLaunch: true) + let route = WorkspaceRoute() let opening = RecordingWindowOpening() opening.onOpen = { opening.destinationAtOpen = route.destination } @@ -83,7 +62,7 @@ final class WorkspaceRouteTests: XCTestCase { } func testExplicitSettingsPresentationSelectsBeforeOpeningWorkspace() { - let route = WorkspaceRoute(opensWorkspaceAtLaunch: true) + let route = WorkspaceRoute() let opening = RecordingWindowOpening() opening.onOpen = { opening.destinationAtOpen = route.destination } @@ -95,7 +74,7 @@ final class WorkspaceRouteTests: XCTestCase { } func testInWorkspaceNavigationChangesDestinationWithoutOpeningWindow() { - let route = WorkspaceRoute(opensWorkspaceAtLaunch: true) + let route = WorkspaceRoute() let opening = RecordingWindowOpening() route.handle(.navigate(destination: .servers), using: opening) @@ -105,7 +84,7 @@ final class WorkspaceRouteTests: XCTestCase { } func testExplicitHomePresentationSelectsHomeBeforeOpeningWorkspace() { - let route = WorkspaceRoute(opensWorkspaceAtLaunch: true) + let route = WorkspaceRoute() let opening = RecordingWindowOpening() opening.onOpen = { opening.destinationAtOpen = route.destination } route.navigate(to: .diagnostics) @@ -118,7 +97,7 @@ final class WorkspaceRouteTests: XCTestCase { } func testPassiveReopenRetainsCurrentDestination() { - let route = WorkspaceRoute(opensWorkspaceAtLaunch: true) + let route = WorkspaceRoute() let opening = RecordingWindowOpening() opening.onOpen = { opening.destinationAtOpen = route.destination } route.handle(.navigate(destination: .diagnostics), using: opening)