mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-30 14:13:50 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 图标缺失即系统侧注册滞后的直接证据)。行动拍用尽后追加一拍只检查拍记录终态。
|
||||
|
||||
Reference in New Issue
Block a user