From 17c9f15928e9dccc38d5d916d6af2dbe360cd698 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Thu, 22 Oct 2026 03:23:54 +0800 Subject: [PATCH] fix(window): bring presented window reliably to front at launch Keyboard-launcher launches (Raycast/Spotlight Enter) left the LSUIElement app unforegrounded with a missing Dock icon until Mission Control forced a refresh. Investigation across real-machine rounds (recorded in the ADR-0017 supplements) pinned three cooperating causes: - The launch activation retry never started at launch: the workspace window's participation events (anchor attach / occlusion / key) could all miss the launch moment when content installed before the window became visible and the visibility notification fired before the anchor attached. A deferred visibility rescan plus re-armable chain (until truly settled) close this gap. - NSApp.isActive can report an activation illusion while the menu bar still belongs to the previous frontmost app, so the settle check now requires both isActive and menu bar ownership. - A same-turn accessory->regular bounce is coalesced away by the system; forced activation is a three-hop sequence with real settle delays (accessory -> 0.1s -> regular -> 0.15s -> activate), which re-registers the app with WindowServer/Dock and reclaims the menu bar. Each acting attempt also re-asserts makeKeyAndOrderFront and orderFrontRegardless as a visible-layer fallback. Diagnostics: launchActivationRetry and windowAnchorAttached events (unified-log category "window") record per-attempt facts (activation, local policy, frontmost app, menu bar owner, space, window visibility); user-environment facts stay out of shared diagnostic reports. --- .../DiagnosticsWorkflow.swift | 3 + .../App/Composition/MainApp.swift | 15 +- .../Composition/WindowActivationPolicy.swift | 199 +++++++++++++- .../Domain/Runtime/RuntimeLog.swift | 32 +++ .../WindowActivationPolicyTests.swift | 244 +++++++++++++++++- docs/adr/0017-adaptive-app-form.md | 14 + 6 files changed, 497 insertions(+), 10 deletions(-) diff --git a/ShadowsocksX-NG2/App/Application/DiagnosticsWorkflow/DiagnosticsWorkflow.swift b/ShadowsocksX-NG2/App/Application/DiagnosticsWorkflow/DiagnosticsWorkflow.swift index c7211af2..3a46f1f4 100644 --- a/ShadowsocksX-NG2/App/Application/DiagnosticsWorkflow/DiagnosticsWorkflow.swift +++ b/ShadowsocksX-NG2/App/Application/DiagnosticsWorkflow/DiagnosticsWorkflow.swift @@ -81,6 +81,9 @@ extension RuntimeLogEvent { return "diagnostics report exported (redacted)" case .settingsUnreadable: return "settings unreadable, falling back to factory defaults" + // 前台应用 bundle id 等用户环境事实只进应用内日志查看器,不进共享报告。 + case .launchActivationRetry, .windowAnchorAttached: + return nil } } } diff --git a/ShadowsocksX-NG2/App/Composition/MainApp.swift b/ShadowsocksX-NG2/App/Composition/MainApp.swift index bfa42f4a..2e6cbd49 100644 --- a/ShadowsocksX-NG2/App/Composition/MainApp.swift +++ b/ShadowsocksX-NG2/App/Composition/MainApp.swift @@ -112,7 +112,7 @@ struct ShadowsocksXNG2App: App { } } -/// 随窗激活策略的真实 NSApp 落点。 +/// 随窗激活策略与激活重试的真实 NSApp 落点。 @MainActor private final class NSAppWindowActivationApplier: WindowActivationPolicyApplying { func apply(_ policy: NSApplication.ActivationPolicy) { @@ -122,6 +122,19 @@ private final class NSAppWindowActivationApplier: WindowActivationPolicyApplying NSApp.activate(ignoringOtherApps: true) } } + + var isAppActive: Bool { NSApp.isActive } + + var ownsMenuBar: Bool { + NSWorkspace.shared.menuBarOwningApplication?.bundleIdentifier + == Bundle.main.bundleIdentifier + } + + var isPolicyRegular: Bool { NSApp.activationPolicy() == .regular } + + func activateApp() { + NSApp.activate(ignoringOtherApps: true) + } } /// 状态菜单的标签视图(状态栏图标)。图标为静态 SF Symbol,不随代理状态 diff --git a/ShadowsocksX-NG2/App/Composition/WindowActivationPolicy.swift b/ShadowsocksX-NG2/App/Composition/WindowActivationPolicy.swift index 916d05ad..a819a51c 100644 --- a/ShadowsocksX-NG2/App/Composition/WindowActivationPolicy.swift +++ b/ShadowsocksX-NG2/App/Composition/WindowActivationPolicy.swift @@ -6,6 +6,40 @@ import SwiftUI @MainActor protocol WindowActivationPolicyApplying: AnyObject { func apply(_ policy: NSApplication.ActivationPolicy) + + /// 应用是否已激活(NSApp.isActive 的可注入口径)。 + var isAppActive: Bool { get } + + /// 菜单栏是否实际归属本应用(NSWorkspace.menuBarOwningApplication 的 + /// 可注入口径)。激活自检第二信号:失败场景实测 isActive 与 frontmost + /// 均指向本应用、重试即停,但 Dock 图标缺失、窗口在后——单看 isActive + /// 无法分辨这种系统侧注册滞后,菜单栏归属仍指此前台应用才是真相。 + var ownsMenuBar: Bool { get } + + /// 进程本地激活策略是否已为 regular 的可注入口径。注意本地值可能已 + /// regular 而系统侧注册仍滞后——Dock 图标缺失即此态,故仅作诊断事实。 + var isPolicyRegular: Bool { get } + + /// 请求激活应用(NSApp.activate 的可注入口径)。 + func activateApp() +} + +/// 启动激活重试的节拍调度口。生产经 Task+sleep 延迟执行;测试实现收集 +/// 回调手动驱动,获得与墙钟无关的确定性节奏。 +@MainActor +protocol ActivationTickScheduling: AnyObject { + func scheduleTick(after interval: TimeInterval, _ tick: @escaping @MainActor @Sendable () -> Void) +} + +@MainActor +private final class TaskActivationTickScheduler: ActivationTickScheduling { + func scheduleTick(after interval: TimeInterval, _ tick: @escaping @MainActor @Sendable () -> Void) + { + Task { + try? await Task.sleep(for: .seconds(interval)) + tick() + } + } } @MainActor @@ -13,10 +47,37 @@ final class WindowActivationPolicyCoordinator: ObservableObject { private let anchoredWindows = NSHashTable.weakObjects() private var openWindows: Set = [] private let applier: any WindowActivationPolicyApplying + private let activationRetryInterval: TimeInterval + private let activationRetryLimit: Int + private let policySettleInterval: TimeInterval + private let activationSettleInterval: TimeInterval + private let launchRescanInterval: TimeInterval + private let tickScheduler: any ActivationTickScheduling + /// 激活已真正收敛(双信号齐备)后的永久闩:此后任何事件不再重启链, + /// 避免会话中抢焦点。 + private var activationSettled = false + /// 重试链是否在调度中。链可能在窗口尚未可见时静默终结(key 先于可见的 + /// 呈现次序),未收敛前由参与事件重新拉起(re-arm)。 + private var isRetryChainActive = false - /// `applier` 注入以便 hermetic 单测;生产实现里单测 host 不触碰真实 NSApp。 - init(applying applier: any WindowActivationPolicyApplying) { + /// `applier` 与 `tickScheduler` 注入以便 hermetic 单测;生产实现里单测 + /// host 不触碰真实 NSApp、不依赖墙钟。 + init( + applying applier: any WindowActivationPolicyApplying, + activationRetryInterval: TimeInterval = 0.2, + activationRetryLimit: Int = 5, + policySettleInterval: TimeInterval = 0.1, + activationSettleInterval: TimeInterval = 0.15, + launchRescanInterval: TimeInterval = 0.5, + tickScheduler: (any ActivationTickScheduling)? = nil + ) { self.applier = applier + self.activationRetryInterval = activationRetryInterval + self.activationRetryLimit = activationRetryLimit + self.policySettleInterval = policySettleInterval + self.activationSettleInterval = activationSettleInterval + self.launchRescanInterval = launchRescanInterval + self.tickScheduler = tickScheduler ?? TaskActivationTickScheduler() let center = NotificationCenter.default center.addObserver( self, selector: #selector(windowDidBecomeKey(_:)), @@ -34,13 +95,40 @@ final class WindowActivationPolicyCoordinator: ObservableObject { } /// 锚点视图捕获到宿主窗口时上交(幂等)。窗口已可见才立刻收敛(覆盖不走 - /// key 事件的可见路径);静默启动预建未呈现的窗口保持 accessory,等打开 - /// 后的 key 事件。 + /// key 事件的可见路径);尚未可见则安排一次延迟重扫兜底——可见化通知可能 + /// 早于锚点附着被错过(实测失败场景启动期无任何事件即此序)。 func anchorDidAttach(to window: NSWindow) { - anchoredWindows.add(window) + if !anchoredWindows.contains(window) { + anchoredWindows.add(window) + RuntimeLog.emit( + .windowAnchorAttached(visible: window.isVisible, key: window.isKeyWindow)) + } if window.isVisible { openWindows.insert(ObjectIdentifier(window)) applier.apply(.regular) + // 键盘启动器(Raycast/Spotlight 回车启动)无关联点击,启动瞬间的 + // activate 请求可能被焦点抢占保护忽略,窗口可见但不成为 key; + // 补一次置前成 key,激活被采纳时窗口即在前台。 + window.makeKeyAndOrderFront(nil) + startLaunchActivationRetry(for: window) + } else { + scheduleLaunchVisibilityRescan() + } + } + + /// 锚点附着后的一次性兜底重扫:附着时窗口尚未可见、其可见化通知又早于 + /// 附着被错过时,这里补上参与与激活重试。静默启动的窗口届时仍不可见, + /// 自然不触发。 + private func scheduleLaunchVisibilityRescan() { + tickScheduler.scheduleTick(after: launchRescanInterval) { [weak self] in + guard let self, !self.activationSettled, !self.isRetryChainActive else { return } + guard let window = self.anchoredWindows.allObjects.first(where: { $0.isVisible }) + else { return } + if self.openWindows.insert(ObjectIdentifier(window)).inserted { + self.applier.apply(.regular) + window.makeKeyAndOrderFront(nil) + } + self.startLaunchActivationRetry(for: window) } } @@ -48,6 +136,9 @@ final class WindowActivationPolicyCoordinator: ObservableObject { guard let window = trackedWindow(in: notification) else { return } openWindows.insert(ObjectIdentifier(window)) applier.apply(.regular) + // 窗口可能在 attach/可见化通知之前先成为 key(SwiftUI 呈现次序不同): + // 该路径同样要启动启动激活重试,否则失败场景下弹跳序列永不执行。 + startLaunchActivationRetry(for: window) } @objc private func windowWillClose(_ notification: Notification) { @@ -64,7 +155,103 @@ final class WindowActivationPolicyCoordinator: ObservableObject { @objc private func windowVisibilityChanged(_ notification: Notification) { guard let window = trackedWindow(in: notification), window.isVisible else { return } let inserted = openWindows.insert(ObjectIdentifier(window)).inserted - if inserted { applier.apply(.regular) } + if inserted { + applier.apply(.regular) + // 不走 key 事件的首次可见化同样补置前,缘由见 anchorDidAttach。 + window.makeKeyAndOrderFront(nil) + } + // 可见化无论是否首次插入都尝试拉起重试:链可能在窗口尚未可见时 + // 静默终结过(key 先行的呈现次序),未收敛前由此 re-arm。 + startLaunchActivationRetry(for: window) + } + + /// 启动激活重试(未收敛前可 re-arm):首拍 activate 是否被焦点抢占保护 + /// 忽略随当时前台应用而变;短窗口内轮询双信号(激活 + 菜单栏归属), + /// 不齐则重试 activate 并置前,收敛后永久闩死、次数用尽或窗口关闭则 + /// 结束本链(后续参与事件可再拉起,直到收敛)。双击启动首拍即收敛。 + private func startLaunchActivationRetry(for window: NSWindow) { + guard !activationSettled, !isRetryChainActive else { return } + isRetryChainActive = true + scheduleActivationRetryTick(for: window, attemptsLeft: activationRetryLimit) + } + + private func endRetryChain() { + isRetryChainActive = false + } + + private func scheduleActivationRetryTick(for window: NSWindow, attemptsLeft: Int) { + tickScheduler.scheduleTick(after: activationRetryInterval) { [weak self] in + self?.runActivationRetryTick(for: window, attemptsLeft: attemptsLeft) + } + } + + private func runActivationRetryTick(for window: NSWindow, attemptsLeft: Int) { + let visible = window.isVisible + let attempt = activationRetryLimit - attemptsLeft + 1 + let menuBarOwner = NSWorkspace.shared.menuBarOwningApplication?.bundleIdentifier ?? "-" + let active = applier.isAppActive + RuntimeLog.emit( + .launchActivationRetry( + attempt: attempt, active: active, policyRegular: applier.isPolicyRegular, + frontmost: NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "-", + menuBarOwner: menuBarOwner, onActiveSpace: window.isOnActiveSpace, + windowVisible: visible)) + guard visible else { + // 窗口尚未可见(key 先行的呈现次序)或已关闭:结束本链留待可见化 + // 事件或兜底重扫 re-arm。 + endRetryChain() + return + } + // 双信号收敛判据:isActive 可能为真而菜单栏实际仍归此前台应用 + // (注册滞后的真相),此时继续重试——重置窗口置前本身就能盖回 + // 可见层,后续弹跳再补 Dock 图标注册。 + if applier.isAppActive && applier.ownsMenuBar { + activationSettled = true + endRetryChain() + return + } + guard attemptsLeft > 0 else { + endRetryChain() + return + } + if attempt == 1 { + // 首拍仍用普通 activate(重开等点击上下文里足以生效,避免无谓的 + // Dock 图标闪烁)。 + applier.activateApp() + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + scheduleActivationRetryTick(for: window, attemptsLeft: attemptsLeft - 1) + } else { + scheduleForcedActivationSequence(for: window, attemptsLeft: attemptsLeft - 1) + } + } + + /// 强制激活序列(跨拍沉淀):失败场景实测 Dock 图标从未出现、进调度 + /// 中心才刷新——启动早期的 regular 策略切换未注册到 WindowServer/Dock, + /// activate 因此全程被拒。同拍 accessory→regular 弹跳会被系统合并吞掉, + /// 必须拆成有真实间隔的三跳:降 accessory → 沉淀 → 回 regular(迫使 + /// 重新注册)→ 沉淀 → 激活置前。末跳续排主链保持单线调度。 + private func scheduleForcedActivationSequence(for window: NSWindow, attemptsLeft: Int) { + applier.apply(.accessory) + tickScheduler.scheduleTick(after: policySettleInterval) { [weak self] in + guard let self, window.isVisible else { + self?.endRetryChain() + return + } + self.applier.apply(.regular) + self.tickScheduler.scheduleTick(after: self.activationSettleInterval) { [weak self] in + guard let self, window.isVisible else { + self?.endRetryChain() + return + } + if !(self.applier.isAppActive && self.applier.ownsMenuBar) { + self.applier.activateApp() + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + } + self.scheduleActivationRetryTick(for: window, attemptsLeft: attemptsLeft) + } + } } private func trackedWindow(in notification: Notification) -> NSWindow? { diff --git a/ShadowsocksX-NG2/Domain/Runtime/RuntimeLog.swift b/ShadowsocksX-NG2/Domain/Runtime/RuntimeLog.swift index de5f8e35..aa7adc4b 100644 --- a/ShadowsocksX-NG2/Domain/Runtime/RuntimeLog.swift +++ b/ShadowsocksX-NG2/Domain/Runtime/RuntimeLog.swift @@ -48,6 +48,23 @@ enum RuntimeLogEvent: Equatable, CustomStringConvertible, Sendable { /// 诊断日志由查看器呈现;完整日志查看器属于独立的诊断界面。 case settingsUnreadable(detail: String) + /// 启动激活重试事实(键盘启动器场景诊断):attempt 从 1 起;active 为该拍 + /// 检查时的应用激活状态;policyRegular 为进程本地策略是否已 regular(本地 + /// 为真而 Dock 图标缺失说明系统侧注册滞后);frontmost 为当时前台应用 + /// bundle id(无则 "-");menuBarOwner 为菜单栏实际归属应用(active=true 而 + /// menuBarOwner 非本应用即激活假象,重试继续);onActiveSpace 为主窗口是否 + /// 在当前空间;windowVisible 为该拍窗口是否可见(首拍 false 说明链在窗口 + /// 呈现完成前启动、已终结待 re-arm)。正常启动只有 attempt=1 一条且各信号 + /// 一致。 + case launchActivationRetry( + attempt: Int, active: Bool, policyRegular: Bool, frontmost: String, + menuBarOwner: String, onActiveSpace: Bool, windowVisible: Bool) + + /// 窗口锚点附着事实(ADR 0017 激活协调):锚点上交宿主窗口时记录当时的 + /// 可见/key 状态——用于定位启动呈现路径下附着与可见化的先后次序(失败 + /// 场景启动期无任何激活事件时由此排查附着是否发生、时机如何)。 + case windowAnchorAttached(visible: Bool, key: Bool) + case connectivityMeasurement(id: UUID, entry: ConnectivityTrace) /// stderr 行式输出(统一行格式,见 `LogLine`);wrapper 侧收敛进 @@ -112,6 +129,16 @@ enum RuntimeLogEvent: Equatable, CustomStringConvertible, Sendable { return "diagnostics report exported (redacted)" case .settingsUnreadable(let detail): return "settings unreadable, falling back to factory defaults: \(detail)" + case .launchActivationRetry( + let attempt, let active, let policyRegular, let frontmost, let menuBarOwner, + let onActiveSpace, let windowVisible): + return + "launch activation retry #\(attempt) active=\(active)" + + " policy=\(policyRegular ? "regular" : "accessory")" + + " frontmost=\(frontmost) menubar=\(menuBarOwner)" + + " space=\(onActiveSpace ? "on" : "off") visible=\(windowVisible)" + case .windowAnchorAttached(let visible, let key): + return "window anchor attached visible=\(visible) key=\(key)" } } } @@ -180,6 +207,11 @@ extension RuntimeLogEvent { case .diagnosticsExported: return SystemLogRoute( level: .default, category: "diagnostics", text: description, privateDetail: nil) + case .launchActivationRetry, .windowAnchorAttached: + // 前台应用 bundle id 等用户环境事实:进应用内日志查看器与统一日志, + // 不进共享诊断报告(白名单见诊断 module)。 + return SystemLogRoute( + level: .default, category: "window", text: description, privateDetail: nil) } } } diff --git a/ShadowsocksX-NG2/Tests/Workspace/WindowActivationPolicyTests.swift b/ShadowsocksX-NG2/Tests/Workspace/WindowActivationPolicyTests.swift index 2982f523..cbed7bd8 100644 --- a/ShadowsocksX-NG2/Tests/Workspace/WindowActivationPolicyTests.swift +++ b/ShadowsocksX-NG2/Tests/Workspace/WindowActivationPolicyTests.swift @@ -4,10 +4,12 @@ import XCTest @testable import ShadowsocksX_NG2 /// Anchored app windows share activation policy; foreign windows are ignored. -/// Notifications and an injected spy keep these tests independent of presentation. +/// Notifications, an injected spy, and a manually driven tick queue keep these +/// tests independent of presentation and wall-clock timing. @MainActor final class WindowActivationPolicyTests: XCTestCase { private var spy: SpyApplier! + private var tickScheduler: SpyTickScheduler! private var coordinator: WindowActivationPolicyCoordinator! private var window: NSWindow! private var foreignWindow: NSWindow! @@ -15,12 +17,18 @@ final class WindowActivationPolicyTests: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() spy = SpyApplier() - coordinator = WindowActivationPolicyCoordinator(applying: spy) + tickScheduler = SpyTickScheduler() + coordinator = WindowActivationPolicyCoordinator( + applying: spy, + tickScheduler: tickScheduler) // 离屏裸窗口仅作通知对象身份:不呈现、不抢 key,创建本身不触碰 // WindowServer 的窗口呈现。 window = Self.makeOffscreenWindow() foreignWindow = Self.makeOffscreenWindow() coordinator.anchorDidAttach(to: window) + // 消化首个不可见锚点安排的兜底重扫(此时无可见窗口,必然空转), + // 后续用例的 pending 队列保持确定。 + tickScheduler.drainOne() } override func tearDownWithError() throws { @@ -29,14 +37,52 @@ final class WindowActivationPolicyTests: XCTestCase { try super.tearDownWithError() } - /// 策略落点 spy(ADR 0017):只记录目标策略,不触碰真实 NSApp。 + /// 策略与激活落点 spy(ADR 0017):只记录调用,不触碰真实 NSApp。 + /// `isActive`/`ownsMenuBar` 默认为真:启动激活重试首拍即短路,既有用例 + /// 不受异步影响。 @MainActor private final class SpyApplier: WindowActivationPolicyApplying { var applied: [NSApplication.ActivationPolicy] = [] + var isActive = true + var ownsMenuBar = true + var isPolicyRegular = true + var activationCalls = 0 + /// 第 N 次 activate 后视为激活成功(模拟稍后才成功)。 + var becomesActiveAfterCalls: Int? func apply(_ policy: NSApplication.ActivationPolicy) { applied.append(policy) } + + var isAppActive: Bool { isActive } + + func activateApp() { + activationCalls += 1 + if let becomesActiveAfterCalls, activationCalls >= becomesActiveAfterCalls { + isActive = true + } + } + } + + /// 收集重试节拍的手动调度 spy:按需逐拍驱动,不依赖墙钟。 + @MainActor + private final class SpyTickScheduler: ActivationTickScheduling { + var pending: [@MainActor @Sendable () -> Void] = [] + + func scheduleTick( + after interval: TimeInterval, _ tick: @escaping @MainActor @Sendable () -> Void + ) { + pending.append(tick) + } + + /// 手动驱动一拍已排定的重试;无待执行拍时返回 false。执行该拍可能 + /// 在队尾续排下一拍。 + @discardableResult + func drainOne() -> Bool { + guard !pending.isEmpty else { return false } + pending.removeFirst()() + return true + } } private static func makeOffscreenWindow() -> NSWindow { @@ -111,6 +157,7 @@ final class WindowActivationPolicyTests: XCTestCase { visible.pretendVisible = true post(NSWindow.didChangeOcclusionStateNotification, on: visible) XCTAssertEqual(spy.applied.last, .regular) + XCTAssertEqual(visible.orderFrontCalls, 1, "首次可见化必须置前成 key") post(NSWindow.didBecomeKeyNotification, on: window) spy.applied.removeAll() post(NSWindow.willCloseNotification, on: window) @@ -132,9 +179,200 @@ final class WindowActivationPolicyTests: XCTestCase { post(NSWindow.willCloseNotification, on: visible) XCTAssertEqual(spy.applied.last, .accessory) } + + // MARK: 首次参与收敛置前 + + func testVisibleAttachAppliesRegularAndOrdersFront() { + let visible = VisibilityWindow( + contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + visible.pretendVisible = true + + coordinator.anchorDidAttach(to: visible) + + XCTAssertEqual(spy.applied, [.regular]) + XCTAssertEqual(visible.orderFrontCalls, 1) + } + + func testOcclusionInsertionOrdersFrontOnce() { + let visible = VisibilityWindow( + contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + coordinator.anchorDidAttach(to: visible) + visible.pretendVisible = true + post(NSWindow.didChangeOcclusionStateNotification, on: visible) + post(NSWindow.didChangeOcclusionStateNotification, on: visible) + + XCTAssertEqual(visible.orderFrontCalls, 1, "重复可见化不得反复置前") + } + + // MARK: 启动激活重试 + + func testLaunchRetryReactivatesWhileInactiveAndStopsAtLimit() { + spy.isActive = false + let visible = VisibilityWindow( + contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + visible.pretendVisible = true + coordinator.anchorDidAttach(to: visible) + + XCTAssertEqual(tickScheduler.pending.count, 1) + tickScheduler.drainOne() + XCTAssertEqual(spy.activationCalls, 1) + XCTAssertEqual( + spy.applied.filter { $0 == .accessory }, [], "首拍必须先走普通 activate,不弹跳") + while tickScheduler.drainOne() {} + XCTAssertEqual(spy.activationCalls, 5, "默认上限 5 次激活(1 普通 + 4 次弹跳末跳)") + XCTAssertEqual( + spy.applied.filter { $0 == .accessory }.count, 4, + "第二拍起每次强制激活都要降一次 accessory") + XCTAssertEqual( + spy.applied.filter { $0 == .regular }.count, 5, + "attach 一次 regular + 每次弹跳回一次 regular") + XCTAssertEqual(visible.orderFrontCalls, 6, "attach 置前一次,每次激活后再置前") + XCTAssertEqual(visible.orderRegardlessCalls, 5) + XCTAssertTrue(tickScheduler.pending.isEmpty, "终态检查拍后不得再排") + } + + func testLaunchRetryStopsOnceAppActive() { + spy.isActive = false + spy.becomesActiveAfterCalls = 2 + let visible = VisibilityWindow( + contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + visible.pretendVisible = true + coordinator.anchorDidAttach(to: visible) + + while tickScheduler.drainOne() {} + + XCTAssertEqual(spy.activationCalls, 2, "已激活后的检查不得再激活") + XCTAssertEqual(spy.applied.suffix(2), [.accessory, .regular], "弹跳序列在激活成功处收敛") + XCTAssertTrue(tickScheduler.pending.isEmpty, "已激活后不得再排下一拍") + } + + func testLaunchRetryStopsWhenWindowCloses() { + spy.isActive = false + let visible = VisibilityWindow( + contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + visible.pretendVisible = true + coordinator.anchorDidAttach(to: visible) + visible.pretendVisible = false + + tickScheduler.drainOne() + + XCTAssertEqual(spy.activationCalls, 0, "窗口已不可见不得再激活") + XCTAssertTrue(tickScheduler.pending.isEmpty) + } + + func testForcedSequenceAbortsWhenWindowClosesMidBounce() { + spy.isActive = false + let visible = VisibilityWindow( + contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + visible.pretendVisible = true + coordinator.anchorDidAttach(to: visible) + tickScheduler.drainOne() + tickScheduler.drainOne() + XCTAssertEqual(spy.applied, [.regular, .accessory], "第二拍已降级 accessory 待弹跳") + visible.pretendVisible = false + + while tickScheduler.drainOne() {} + + XCTAssertEqual(spy.activationCalls, 1, "窗口关闭后弹跳末跳不得再激活") + XCTAssertEqual( + spy.applied, [.regular, .accessory], "中途关闭后不得再回 regular") + } + + func testLaunchRetryRunsOncePerProcess() { + spy.isActive = false + let first = VisibilityWindow( + contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + first.pretendVisible = true + let second = VisibilityWindow( + contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + second.pretendVisible = true + coordinator.anchorDidAttach(to: first) + coordinator.anchorDidAttach(to: second) + + XCTAssertEqual(tickScheduler.pending.count, 1, "后续窗口参与不得再启动一轮重试") + } + + /// 激活假象(实测失败场景):isActive 与 frontmost 均指向本应用、重试即停, + /// 但菜单栏仍归此前台应用——双信号不齐必须继续行动。 + func testActivationIllusionKeepsRetryingUntilMenuBarReturns() { + spy.isActive = true + spy.ownsMenuBar = false + let visible = VisibilityWindow( + contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + visible.pretendVisible = true + coordinator.anchorDidAttach(to: visible) + + tickScheduler.drainOne() + XCTAssertEqual(visible.orderRegardlessCalls, 1, "active=true 但菜单栏未归位也要抬窗") + spy.ownsMenuBar = true + while tickScheduler.drainOne() {} + + XCTAssertEqual(visible.orderRegardlessCalls, 1, "菜单栏归位后不得再行动") + XCTAssertTrue(tickScheduler.pending.isEmpty) + } + + // MARK: 启动期次序兜底 + + /// 锚点附着时窗口尚未可见、其可见化通知又早于附着被错过:延迟重扫补上 + /// 参与与激活重试(实测失败场景启动期无任何事件即此序)。 + func testDeferredRescanRecoversWindowVisibleAfterAttach() { + spy.isActive = false + let visible = VisibilityWindow( + contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + coordinator.anchorDidAttach(to: visible) + visible.pretendVisible = true + + tickScheduler.drainOne() + XCTAssertEqual(spy.applied, [.regular], "重扫对已可见窗口补上参与并拉起重试") + XCTAssertEqual(visible.orderFrontCalls, 1) + while tickScheduler.drainOne() {} + } + + func testDeferredRescanSkipsStillInvisibleWindow() { + let silent = VisibilityWindow( + contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + coordinator.anchorDidAttach(to: silent) + + while tickScheduler.drainOne() {} + + XCTAssertTrue(spy.applied.isEmpty, "静默启动的不可见窗口不得由重扫触发参与") + } + + /// 链在窗口呈现完成前启动、首拍因不可见静默终结后,可见化事件必须能 + /// 重新拉起(re-arm),直到真正收敛。 + func testVisibilityEventRearmsChainEndedBeforeWindowVisible() { + spy.isActive = false + spy.becomesActiveAfterCalls = 1 + let visible = VisibilityWindow( + contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + visible.pretendVisible = true + coordinator.anchorDidAttach(to: visible) + visible.pretendVisible = false + + while tickScheduler.drainOne() {} + XCTAssertEqual(spy.activationCalls, 0, "首拍窗口不可见时链静默终结") + + visible.pretendVisible = true + post(NSWindow.didChangeOcclusionStateNotification, on: visible) + tickScheduler.drainOne() + XCTAssertEqual(spy.activationCalls, 1, "可见化事件必须能重启已终结的链") + while tickScheduler.drainOne() {} + XCTAssertTrue(tickScheduler.pending.isEmpty) + } } private final class VisibilityWindow: NSWindow { var pretendVisible = false override var isVisible: Bool { pretendVisible } + + /// 离屏拦截:真实置前会在测试宿主呈现窗口,只记录调用。 + var orderFrontCalls = 0 + override func makeKeyAndOrderFront(_ sender: Any?) { + orderFrontCalls += 1 + } + + var orderRegardlessCalls = 0 + override func orderFrontRegardless() { + orderRegardlessCalls += 1 + } } diff --git a/docs/adr/0017-adaptive-app-form.md b/docs/adr/0017-adaptive-app-form.md index 585f46df..15c143a5 100644 --- a/docs/adr/0017-adaptive-app-form.md +++ b/docs/adr/0017-adaptive-app-form.md @@ -36,3 +36,17 @@ The user-confirmed rule-browser simplification adds a single independent convers ## 2026-10-21 补充:启动窗口前置 静默启动关闭时,`Window` scene 会自动呈现主窗口;窗口激活协调器切换应用为 `.regular` 后调用 `NSApp.activate(ignoringOtherApps:)`,确保应用与主窗口进入前台。静默启动开启时,窗口不呈现,不触发此激活,仍以 accessory 形态启动。 + +## 2026-10-22 补充:启动激活重试 + +键盘启动器(Raycast、Spotlight 回车启动)没有关联的点击事件,启动瞬间发出的 activate 请求是否被系统的焦点抢占保护忽略,随当时前台应用而变——实测窗口可见、应用却经常进不了前台(鼠标双击启动因关联点击获得激活许可,不受影响;单次补 `makeKeyAndOrderFront` 亦不足以稳定解决)。为此协调器在窗口首次参与收敛的两条路径(启动呈现时的锚点 attach、不经 key 事件的首次可见化)上采取两层措施:其一,与 `.regular` 策略同拍调用 `NSApp.activate(ignoringOtherApps:)` 并对该窗口 `makeKeyAndOrderFront`;其二,进程内一次性的激活重试——默认每 0.2 秒检查 `NSApp.isActive`,未激活则重试 activate 并置前,应用激活、次数用尽(默认 5 次,约 1 秒窗口)或窗口关闭即止。双击启动首拍已激活,第一次检查即短路退出,不产生额外激活请求。 + +激活查询与请求经 applier 协议注入(生产为 NSApp 落点),延迟调度经初始化注入(生产为 Task+sleep),单测以收集回调的手动驱动获得确定性;`didBecomeKey` 路径(已是 key)不参与重试,重复可见化不反复置前。 + +实测重试后结果仍随前台应用分化(Xcode、Safari 等让位后激活成功;Finder、Chrome 持续不让位,重试全程被拒)——macOS 14 起 activate 为协作式激活,让位与否由前台应用决定。为此每拍重试在 activate 之外追加 `orderFrontRegardless`:激活持续被拒时也把主窗口抬到可见层(无键盘焦点,用户点击即完成激活)。每拍同时发射 `launchActivationRetry` 事件(拍数、激活状态、前台应用 bundle id、窗口是否在当前空间)进应用内日志查看器与统一日志(新 category `window`;前台应用属用户环境事实,不进共享诊断报告),用于定位残余失败场景。 + +诊断事件的首轮实测推翻了 isActive 单信号判据:失败场景 #1 即 active=true、policy=regular、frontmost=本应用,重试当即短路——与成功场景日志完全一致,而视觉上 Dock 图标缺失、窗口在后。即 isActive 可能呈现“激活假象”,真相要看菜单栏归属。收敛判据改为双信号:`isAppActive && menuBarOwningApplication == 本应用`;不齐则继续行动(首拍即重置窗口置前,弹跳序列补注册),诊断事件相应记录 menubar 归属。 + +GUI 日志的时序定位到启动路径的真正缺陷:重试链从未在启动时启动——首条 retry 事件距启动 8 秒(用户打开诊断窗口才由其锚点触发);此前轮次观察到的事件均可能是诊断窗口触发的,工作区窗口在启动期一次参与事件都没发出,或链启动后首拍窗口尚未可见、被 isVisible 守卫静默终结且一次性标志已耗尽无法重启。据此三改:其一,链改为可 re-arm——未真正收敛(双信号齐备)前由任何参与/可见化事件重新拉起,收敛后永久闩死,防止会话中抢焦点;其二,锚点附着时尚未可见的窗口安排一次 0.5 秒兜底重扫,覆盖“可见化通知早于附着被错过”的次序(静默启动的窗口届时仍不可见,自然不触发);其三,锚点附着本身发诊断事件(当时的可见/key 状态)、retry 事件补 visible 位,启动期次序从此可观测。 + +再实测锁定真正根因:失败场景中 Dock 图标从未出现,进入调度中心才显示——启动早期的 `setActivationPolicy(.regular)` 未注册到 WindowServer/Dock(进程本地已 regular、系统侧仍视作 accessory),activate 因此全程被拒,此前观察到的“前台应用差异”只是该注册竞态的不同结果。重试序列据此两级设计:首拍仍走普通 activate(重开等点击上下文足以生效,避免无谓的 Dock 图标闪烁);第二拍起走强制弹跳序列。同拍 accessory→regular 弹跳实测会被系统合并吞掉,故拆成有真实间隔的三跳(降 accessory → 沉淀 0.1s → 回 regular 迫使重新注册 → 沉淀 0.15s → 激活置前),末跳续排主链保持单线调度。窗口先于 attach/可见化成为 key 的呈现次序(didBecomeKey 首参路径)同样启动重试,否则失败场景弹跳永不执行;诊断事件补充进程本地策略位(本地 regular 而 Dock 图标缺失即系统侧注册滞后的直接证据)。行动拍用尽后追加一拍只检查拍记录终态。