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.
This commit is contained in:
Qiu Yuzhou
2026-10-22 03:24:14 +08:00
parent f847c01e70
commit 17c9f15928
6 changed files with 497 additions and 10 deletions
@@ -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
}
}
}
+14 -1
View File
@@ -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,不随代理状态
@@ -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<NSWindow>.weakObjects()
private var openWindows: Set<ObjectIdentifier> = []
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? {
@@ -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)
}
}
}
@@ -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
}
}
+14
View File
@@ -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 图标缺失即系统侧注册滞后的直接证据)。行动拍用尽后追加一拍只检查拍记录终态。