fix(ng2): present workspace window on every launch

登录启动判定没有可靠的系统 API,uptime 启发式与 launch-at-login 语义
不严格对应(logout/login 不重启时检测不到,启用登录项后开机超窗口期的
手动启动会被误判静默)。按产品决定简化:不再区分启动来源,每次启动都
开主窗口;混合形态(开窗 .regular / 关最后一个窗 .accessory)不变。
This commit is contained in:
Qiu Yuzhou
2026-09-24 23:07:12 +08:00
parent cb4ad5a87d
commit a0137651fa
7 changed files with 17 additions and 108 deletions
+2 -2
View File
@@ -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.
-20
View File
@@ -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
}
}
+2 -10
View File
@@ -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 }
+1 -7
View File
@@ -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)
+3 -11
View File
@@ -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):
@@ -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
@@ -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)