Files
ShadowsocksX-NG/docs/adr/0017-adaptive-app-form.md
Qiu Yuzhou 2241d3f7f9 fix(window): stop policy bouncing during launch activation
Keep regular policy while windows remain open to avoid repeated Dock registration. Share the activation retry budget across visibility events and stop permanently after exhaustion.
2026-10-25 17:50:18 +08:00

12 KiB

Adaptive app form: window-following activation policy with an opt-out silent launch

The GUI returns to runtime activation-policy switching, this time driven entirely by the workspace window's own NSWindow lifecycle: while the workspace window is open the app presents as a regular app (Dock icon, app switcher, default menu bar); when it closes the app returns to the menu-bar accessory baseline. LSUIElement stays in the Info.plist so launches never flash a Dock icon and the unit-test host stays clean; the policy is applied from didBecomeKey (→ regular) and willClose (→ accessory) notifications of the window captured by a zero-size anchor view embedded in the scene content, so the target window is matched by object identity and sheets or auxiliary windows can never drive the switch. NSApp.setActivationPolicy is the only AppKit surface. A new inline-toggled silent launch preference (off by default, persisted in the app's standard UserDefaults domain since the 2026-09-29 amendment; originally its own file) suppresses launch presentation so launches start in the menu-bar baseline; it takes effect on the next launch.

Status

Accepted (2026-09-29). Supersedes ADR-0016's constant menu-bar-only form. The scene model, launch presentation, and status-menu reopening from 0016 carry over unchanged; only the "never a Dock icon" rule is replaced. The 2026-09-24 hybrid's AppKit window adapter stays gone — no window ownership, no opening-intent adapter, no delegate.

(2026-09-29 amendment: silent launch persistence has moved from its own JSON file to the app's standard UserDefaults domain (key silentLaunch), the platform's canonical KV store for GUI-scalar preferences. The separation rationale stands unchanged — it stays out of settings.json, whose record the proxy controller whole-writes from its in-memory snapshot, and the runtime controller chain still never consumes it. The file-form consequence "a persistence failure keeps the toggle at its previous value and names the reason" is dropped: UserDefaults.set reports no failure, so persistence for this presentation-only preference is best-effort. The legacy file never shipped in any release; a one-shot migration preserves its value and deletes it.)

Considered options

  • Drive the switch from the window scene's scenePhase: attempted first and rejected after real-machine testing — on macOS scenePhase follows the application, not the window; closing the window (close button or ⌘W alike) fires no phase event, so the Dock icon never returned to accessory.
  • Keep the constant accessory form (ADR-0016): rejected — the user wants a Dock icon exactly while the workspace window is open, and the window is presented at every launch by default, so the constant-accessory rule no longer matches the product.
  • Distinguish login-item launches from manual launches and present the window only for the latter: rejected — SMAppService.mainApp launches are indistinguishable in-process, so the distinction would need unreliable launch-reason machinery. The silent-launch preference replaces it as an explicit user choice that applies to every launch.
  • Persist silent launch as a field of settings.json: rejected — the proxy controller persists settings as whole in-memory snapshots, so any parallel writer's field would be clobbered by the controller's next write; a separate one-purpose store follows the ActivationStateFileStore precedent and keeps the runtime controller chain from consuming a GUI presentation preference.

Consequences

  • While the workspace window is open the app gains a Dock icon, app-switcher and Cmd-Tab presence, and the default SwiftUI menu bar (⌘W/⌘Q come with it; no custom commands are added yet).
  • With silent launch off, every launch — including login-item launches — presents the window and therefore the Dock icon until the user closes it; with silent launch on, launches start silent and proxy runtime restoration still happens at every launch.
  • ⌘H hiding orders the window out without posting willClose, so hiding is never mistaken for closing: the current form is kept and the Dock icon stays available to unhide.
  • With silent launch on, the scene's pre-built but unpresented window triggers no policy change (not visible, never key): launches start and stay accessory until the user opens the window.
  • The preference is inline-toggled and persisted immediately; it affects only the next launch. (2026-09-29: with the UserDefaults migration the failure clause is historical — UserDefaults.set reports no failure, so persistence is best-effort and the toggle simply reflects the attempted value.)

2026-10-02 amendment: independent rule-report window

The user-confirmed rule-browser simplification adds a single independent conversion-report window. It may remain open after the workspace closes. Activation policy therefore follows all explicitly anchored application windows through one shared coordinator: any open workspace or report window keeps the regular form, and closing the last one returns to accessory. Unanchored sheets and system panels still do not participate; hiding remains distinct from closing. Report launch presentation is suppressed and restoration is disabled, since its contents are an explicitly opened, session-only snapshot.

2026-10-06 补充:独立诊断窗口

诊断从工作区移至唯一独立窗口,入口位于菜单栏“打开主窗口…”之后。诊断窗口共享既有窗口激活协调器,可在工作区关闭后独立存续;最后一个受管窗口关闭才回到 accessory。启动呈现被抑制,会话恢复被禁用,重复打开恢复并前置已有窗口。

诊断轮询按宿主 NSWindow 的真实开关状态运行:打开立即采样,失焦、遮挡、隐藏或最小化时继续每秒刷新,关闭后停止;不依赖 SwiftUI scenePhase 或视图销毁。日志来源选择仅在进程内保留,关闭清理导出成功提示与错误弹窗。诊断功能和 ADR-0006 的原始日志与脱敏报告边界不变。

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 图标缺失即系统侧注册滞后的直接证据)。行动拍用尽后追加一拍只检查拍记录终态。

2026-10-24 补充:应用菜单诊断入口

regular 形态的默认菜单栏在应用名称菜单(CommandGroup(after: .appInfo))加入“诊断…”命令,与状态菜单入口同开唯一诊断窗口;应用处于 accessory 形态(无受管窗口)时不出现该菜单栏,入口自然缺席。诊断命令实现为独立视图文件(App/UI/Diagnostics/DiagnosticsCommands.swift)。同日移除按源码扫描 openWindow 调用点的守卫(原为文件豁免登记制):豁免清单的维护成本高于其拦截价值。开窗纪律改由事实保证——全部 Window scene 集中在组合根(MainApp)一处声明,窗口激活协调只经 .modifier(WindowActivationPolicy(...)) 挂在这些受管 scene 上。

2026-10-25 补充:取消启动策略弹跳并限制重试预算

用户报告启动重试期间 Dock 出现多个重复应用图标,日志显示菜单栏仍归 Xcode、重试多轮从 #1 重新开始。此前通过 accessory → regular 弹跳补注册的方案撤回:窗口打开期间保持 regular,重试只调用 activate、makeKeyAndOrderFront 和 orderFrontRegardless,不再反复触发 Dock 注册。真实 AppKit 落点仅在策略值变化时调用 setActivationPolicy。

默认 5 次激活预算由协调器在进程内共享。窗口尚未可见或暂时不可见时结束当前链,后续窗口事件可续排剩余预算;双信号收敛或预算用尽后永久停止重试,避免开诊断窗口、切换焦点或可见化事件重新启动整轮。0.5 秒启动可见性重扫、双信号收敛检查及末拍诊断日志保留。系统拒绝协作式激活时,仍通过 orderFrontRegardless 抬窗,用户点击窗口完成激活;不再以反复改变应用形态强制争取焦点。