diff --git a/AGENTS.md b/AGENTS.md index 22d4ee1..c7cfc12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -247,7 +247,7 @@ Swift 的 C++ 互操作吞不下,所以 C ABI 这一层不可省。 | 31 | `chore(release): v0.1.4 —— 中英双语` | ✅ | README 英文版转正为默认(HoRNDIS 搜索意图 SEO);**升级后用户必须在 App 内更新一次特权组件**(协议号变了) | | 32 | `feat(gui): 特权组件与 App 版本不一致时给出更新入口` | ✅ | 比的是**库**版本而不是 Info.plist(`swift run` 没有 bundle);只取版本号,构建配置不同不算不一致;协议号没变的版本以前完全没有提示 | | 33 | `chore(release): v0.1.5 —— 组件版本对不上时可一键更新` | ✅ | 协议号仍是 3,旧组件照常能用;升级后管理行会提示更新,不更新也不会坏 | -| 34 | `feat(gui): 侧栏五页布局与 AppKit 主窗口(本分支)` | ✅ | 33 个提交:两栏仪表盘改为五页侧栏;主窗口改 AppKit 自管(第 7 节坑 19、20);sdk 26 门控 chrome(第 7 节坑 19);页面切换零动画(第 7 节坑 22、23,docs/GUI-ARCHITECTURE.md 4.11) | +| 34 | `feat(gui): 侧栏五页布局与 AppKit 主窗口(本分支)` | ✅ | 两栏仪表盘改为五页侧栏;主窗口改 AppKit 自管(第 7 节坑 19、20);sdk 26 门控 chrome(第 7 节坑 19);页面切换零动画(第 7 节坑 22、23,docs/GUI-ARCHITECTURE.md 4.11);无障碍按压与键盘导航(第 7 节坑 24;round 6-10 系统调研与自驱环探索,round 11 定型:移除自驱环,原生 Tab 双向贯通,窗口方向键全态切页 + Return 认领与行级后背,ButtonTurn 激活防插值) | | 35 | `build(ci): split CI into tests, build, and lint workflows` | ✅ | ci.yml's five jobs moved into tests.yml with their steps and checks unchanged, triggers narrowed to PR + push main + manual (tag pushes covered by the self-sufficient release.yml; the swift-app branch is retired); new build.yml (workflow_dispatch only, runner-image and build-type inputs, .app and shared-library artifacts) and lint.yml (clang-format as a hard gate; clang-tidy advisory, since WarningsAsErrors: '' in .clang-tidy is an existing choice, promote it once the backlog is zero); all actions pinned to the latest major, checkout@v7 / upload-artifact@v7, with the .app uploaded via v7 single-file direct upload so the ditto zip arrives as itself | | 36 | `build(ci): add weekly regression, ASan/UBSan, CodeQL, benchmarks, and hardening` | ✅ | tests.yml gains a Monday cron (catches runner-image / new Xcode / latest libusb / xnu ABI drift before the next PR) and an ASan+UBSan job (splits the work with TSan: memory and UB vs concurrency; Sanitizers.cmake only forbids ASan+TSan together); new codeql.yml (github/codeql-action@v4, manual C++ build analysis, push main + cron, a hard complement to advisory lint) and bench.yml (dispatch-only benchmarks, Release without sanitizers, Markdown artifact, hosted-runner noise caveat); dependabot.yml bumps actions weekly in one grouped PR (backs the latest-major convention); tests/lint/build get permissions: contents: read; Xcode selection filters out beta versions | | 37 | `build(ci): translate release and tap workflows to English` | ✅ | release.yml and update-tap.yml translated wholesale (comments, step names, error echoes, and the user-facing Release notes heredoc); release.yml's checkout pin bumped v5 -> v7 per the latest-major convention; the two runtime strings the abi-check greps depend on (ReportSkip's root-skip message and the pair-creation MESSAGE in tests/test_net_link.cc) are now English literals, and tests.yml greps the matching strings byte-exactly (MESSAGE prints on success verified empirically against the vendored doctest 2.4.12) | @@ -667,6 +667,162 @@ TX 就从 4.8 回到了 87 Mbps。 尺寸 560,settle 会立刻把它再长回 592 并重置 min/max,往复循环直至 SIGTRAP 崩溃。min/max 幂等重钉就是终态,frame 不要碰。 +24. **macOS 26.5 把「无障碍按压轮」内排进异步队列的块全数丢弃。** + 侧栏五页在 VoiceOver 里失灵:`AXUIElementPerformAction(kAXPressAction)` + 返回 success,但 `select()` 里 `DispatchQueue.main.async` 的写块正文 + 从来不跑(`Task { @MainActor }` 同死),同轮里只有同步写存活。 + 逐项 bisect(隔离 repro)排除了 `InstantButtonStyle`(孤立复现中四种 + 按钮样式都正常)、`isSelected` trait、`onHover`、`@AppStorage` —— + 「下一 runloop 拍」这一跳本身就是死因。按压发生时 + `NSApp.currentEvent == nil` 是唯一可靠的分流信号:`select()` 现按 + 事件类型决定跳不跳拍(鼠标/键盘轮内照旧跳拍;无障碍轮直写)。键盘 + 轮内直写还有第二坑:换页重渲染会把同一按键事件重新派发,一次按键 + 双步进页 —— 故键盘轮也必须跳拍。探针无需任何权限:自进程 + `AXUIElementCreateApplication` 遍历 + `performAction` + AppStorage + 键读回即可闭环复现。真人 VO/FKA 复核(2026-11-07):方向键与 + `AXPress` 激活正常;Space/Return 确认为**真实缺陷**(真人按压同样 + 无效果,合成事件伪影的怀疑被排除)。(注:早期曾探索由侧栏容器拦截 + Space/Return 并调用 `activateFocusedRow`,该方案在 Round 11 已被完全重构为: + 行自身声明 `.accessibilityAction(.default)` 直写,窗口拦截 Return 认领 + 行级 + `.onKeyPress(.return)` 兜底换页)。已排除的两条路(勿重复施法):① 行按钮各自 + `.focusable()` 会把键盘事件拦进焦点引擎,容器的方向键处理器反而 + 收不到,方向键直接失灵;② `@FocusState` 程序化聚焦是静默 no-op, + AX 读回焦点为空,「指环随选区」走不通。另一个机器级事实:FKA + 开启后方向键被系统焦点引擎接管(「移动焦点」键),任何应用内 + 处理器都拦不住 —— 无 FKA 时容器方向键直切不受影响。实现见 `SidebarShell.rowSelect`、 + `SidebarRow` 与 `SelfAccessibility`; + 跳拍/直写的公共出口在 `ButtonTurn.write`,LogCard 的层级筛选与 + 清空按钮同用一条路(点击筛选/清空不再带激活事务的插值)。 + Round 6(三路系统级调研后定型真机激活管线):real FKA 的 Space + 根本不会以 NSEvent 到达应用 —— universalaccessd 在 HID 层的 + CGEventTap 拦截 Space 与方向键,Space 转成进程外 + `AXUIElementPerformAction` 投到焦点元素,Return 却原样通过; + 合成探针把 NSEvent 注入 `NSApp.sendEvent`,完全绕过该 tap —— + 这就是「无头全过、真机全死」的分歧根源。三条对应管线: + ① 行 AXButton 显式挂 `.accessibilityAction(.default)`(Space 的 + AXPress 落点),写入走新增的 `ButtonTurn.writeDirect` —— 进程外 + AX 轮次里 AppKit 不清 `NSApp.currentEvent`,上一把点击的残留 + 事件会骗 `write` 跳拍,而 26.5 会丢弃无障碍轮次里调度的所有 + 异步块,直写是不依赖轮次判定的唯一写法;② Return / 小键盘 + Enter 在 `SidebarMainWindow.performKeyEquivalent` 拦(AppKit 键 + 等价相位先于 firstResponder 派发,且免增加/卸载监听的生命周期 + 管理),条件是系统级 AX 焦点解析出行标题且 field editor 未激活, + 否则原样放行;③ 聚焦行自身的 `.onKeyPress(.return)` 保留为兜底(Round 11 定型; + 容器仅处理方向键)。同时 + 移除了 `model.fullKeyboardAccess` 门(AppModel 的轮询与属性一并 + 回退):`NSApp.isFullKeyboardAccessEnabled` 读的是旧偏好 + `AppleKeyboardUIMode`(Keyboard ▸ 键盘导航),不是辅助功能面板的 + FKA 开关,拿它当门会对只开后者的用户静默死路;AX 焦点匹配自身 + 就是自限的。已否/缓的路线:`.keyboardShortcut` 是窗口级全局 + 等价物,多行同挂只有第一个生效且会劫持文本输入(否); + 行 `.accessibilityRole` 重塑为 radio 组或可解锁 FKA 的 Tab + 遍历,但会动 AX 结构(按标题匹配的解析与探针都依赖它),暂不 + 做;NSEvent 本地监视器被窗口子类取代(后者免安装/卸载); + `InstantButtonStyle` 改回 `Button(configuration)` 有方向键失灵 + 回归风险(r5 已实测),不动。 + Round 7(真机 FKA-off 两处回归 + 无头探针矩阵终结):① 辅助功 + 能面板的 FKA 真值是 `CFPreferencesCopyAppValue("keyboardAccess + Enabled", "com.apple.universalaccess")`( + `SelfAccessibility.isFullKeyboardAccessOn`,每次现读);实测机 + 上 `AppleKeyboardUIMode=2`(键盘导航开)与面板开关关长期并存, + 所以 Space/Return 两条按键管线(聚焦行 `.onKeyPress` 与窗口 + `performKeyEquivalent`)都改挂这个真值为门 —— 面板开关关掉时 + 行上的 AX 焦点只是「上次浏览残留」(点行或 Tab 摸过后赖着 + 不走),裸 Space/Return 会静默重激活那一行(真机症状:哪里按 + 都跳到管理页),必须保持 inert。行的 AXPress 路径**不设门** + (VoiceOver 无关此开关)。② (探索历史 / superseded —— 现行方向键契约以 Round 11 ② 为准): + 键盘导航(`AppleKeyboardUIMode=2`)下方向键到不了 SwiftUI 容器:AppKit 键导航在 firstResponder + 派发之前吃掉它们(真机症状:先点一下侧栏方向键才活)。修在 + `SidebarMainWindow.performKeyEquivalent`(先于 firstResponder + 相位)认领裸方向键 125/126:条件是 FKA-off + first responder + 为「空」—— nil(SwiftUI 窗口常态,`NSHostingView + .acceptsFirstResponder == false` 探针实测;因此前一轮的 + `initialFirstResponder` 与 present 里的重断言全是死代码, + 已删)或本窗口/contentView 自身;文字视图(日志页与表单编辑 + 器)、分段控件等真实控件持有焦点时照旧放行。步进与 + `SidebarShell.stepSelection` 同为环绕;认领返回 handled,容器 + `.onKeyPress` 不会二次步进。③ ⚠️ 修饰键判据陷阱:方向键 + keyDown 一贯带 `.function` 位(NSModifierFlags 设备无关的硬件 + 键标记,无实体修饰键按下时也在),把 `deviceIndependentFlags + Mask` 整位掩码当和弦禁令会静默拒掉所有裸方向键(`TETHERKIT_FKA_OVERRIDE` + 覆写 + PROBE 打印证据:键进了 `performKeyEquivalent`、箭头分支却没跑);和弦 + 禁令必须是显式集合 `[.capsLock, .shift, .control, .option, + .command]`。④ 无头验证矩阵(注入 `NSApp.sendEvent`,绕不过 + 真实 HID tap,故 real-FKA-on 的 Space/方向键终判仍在真机): + UA-off 下裸下/上方向键即换页(含环绕回卷)、裸 Space/Return + 与「Tab 残留焦点 + Space/Return」全 inert、非选中行 AXPress + 照切、植入 NSTextView 与分段控件持焦时三键全放行、⌘ 和弦 + 穿透;forced-UA-on(DEBUG 覆写真值)下行激活按焦点行切 + 换、Return 在焦点行上正确认领切换、文字视图安全;合成方向键 + 在 forced-UA 下仍经容器直切属无头混成态(真实 FKA-on 下 HID + 已吃掉 NSEvent,不会发生),不是缺陷。探针脚手架中 AxProbe.swift + 已在 Round 7 验证后拆除;DEBUG fkaOverride 作为回归守卫保留。 + Round 8(焦点应答、Tab 归属与选择器收官): + ① 进程内挪 AX 焦点的唯一受支持通道是 SwiftUI 状态包装: + `.focused`/`.accessibilityFocused` 逐行绑定、 + `onChange(of: focusedRow)` 粘合(写后者发 + `kAXFocusedUIElementChanged`);从外部对自己的 + `kAXFocusedUIElement` 做 `AXUIElementSetAttributeValue` 是 + 确认过的 no-op(err=0、焦点仍 nil,探针实证)。 + ② (探索历史 / superseded):Round 8 曾尝试在轮询重渲染后通过 + `.onChange(of: model.status.monotonicNanos)` 与 involvement gate 进行 + 自读系统应答自愈。该方案在 Round 11 原生架构中已完全停用(移除相关自愈机制, + 焦点完全由 SwiftUI 原生引擎与窗口方向键自愈承载)。 + ③ 行 AXPress 由 `.accessibilityAction(.default)` 承载,探针机器证明「激励落点 = 焦点行」;真实 HID 仍属硬件 + 清单(round 7 定型的 universalaccessd 分工不变)。 + ④ segmented Picker 在键盘导航(`AppleKeyboardUIMode=2`)与 + 真 FKA 下按键无响应(r8-B),`.menu` 弹出式是键盘可操作的 + 正统替代;弹出的菜单跑 `NSEventTrackingRunLoopMode`, + `NSApp.sendEvent`/firstResponder/本地事件监视器都够不着, + 头头环境无法合成开菜单/提交 —— 菜单存在性用 CGWindowList + 窗口差分(layer>0、自 pid)取证,AXMenu 计数会被菜单栏基线 + 污染;对 `AXPopUpButton` 写 value 是幽灵成功(err=0、 + `valueSettable=false`、值不变)。 + ⑤ FKA-off 的 Tab 归属:r8 曾用 `.focusable(false)`,后在 r9/r11 重构。 + ⑥ `ButtonTurn` 移入 TetherKitUIModel(可执行目 + 标不能被 `swift test` import),激活逃逸的 commit 语义有 + 6 条单测(TetherKitUIModelTests/ButtonTurnTests)。 + Round 9(真机复检推翻 r8 的 FKA-off Tab 契约): + ① 键盘导航下 Tab 循环原不打进侧栏(行 `.focusable(false)`), + 只要任何控件持有焦点,窗口方向键认领的「焦点为空/窗口自身」前置 + 失效 → Tab 摸不到侧栏且方向键搁浅(用户真机)。契约反转:行 + `.focusable(true)` 改常量 —— r8 探针矩阵的期望项「Tab 永不落行」 + 从此作废,未来探针矩阵先改期望;FKA-off 的 inert 不再靠修饰链、 + 改靠激活动作分流:FKA-off 门内按 `NSApp.currentEvent` 类型区分, + 键盘驱动的原生激活(现场 keyDown)丢弃、鼠标事件放行。⚠️ + `currentEvent` 的读取有两处(此门与 `ButtonTurn.write` 的跳拍 + 判定),均只作否定/分流信号 —— AXPress 轮会残留 + 旧事件(本坑 round 6),别拿它做正向判据。 + ② Return 真机失败补记:窗口 `performKeyEquivalent` 认领的 AX + 自读在合成探针下能解析出行,用户真机 FKA 下解析不出 → Return + 静默穿透。补聚焦行自身的 `.onKeyPress(.return)`(FKA-on 门, + 直调 `rowSelect`),零 AX-read 依赖 —— 无头环境绕不开 HID 层, + 这条链在真机断在哪一环只有真机能验;两级认领先到先消费,重复最 + 坏是幂等换页。小键盘 Enter(keycode 76)仍只有窗口认领覆盖 + (SwiftUI `KeyPress.Key` 无小键盘变体)。 + Round 10(自驱键盘环探索,已在 Round 11 废弃 / superseded): + ⚠️ 注意:Round 10 曾探索在 AppKit 窗口层认领裸 Tab、在 + `AppModel.sidebarRingRow` 上维护自驱键盘环,并用 `SidebarFocusBridge` + 供探针取样。该设计在 Round 11 证实为过度设计且阻断正常焦序,已被完全移除, + 代码中已无 `sidebarRingRow` 等残留,勿再引入。 + Round 11(最终架构:原生焦点循环贯通与方向键全态活化): + ① 根因查明:此前探针未进侧栏的死穴是 `rows` 容器上绑定的 + `.focusable(SelfAccessibility.isFullKeyboardAccessOn)` + (FKA-off 下将整组判定为不可聚焦);容器去 focusable、仅选中行 + 在 FKA-off 下声明 `.focusable(isSelected)`(FKA-on 下各行均可聚焦)。 + Tab 键直接形成「侧栏选中行 ↔ 内容各按钮」的双向原生 Tab 循环。 + ② 方向键活化:`handleArrowKey` 守卫改为仅在 `isFirstResponderEditableText` + 为真时放行文本编辑(移动光标),按钮/卡片/滚动区获焦时方向键随时直步换页, + 换页自愈焦点,用户绝不被卡在当前页。容器 `.onKeyPress` 保留上下方向键作为兜底。 + FKA-on 时方向键由系统焦点引擎接管(HID 层的「移动焦点」键),窗口认领 + 与容器兜底都收不到事件,方向键移动的是 key 焦点本身 —— 预期行为,不是缺陷。 + ③ Return 认领与行级后背:窗口 `performKeyEquivalent` 在第一响应者非 field editor 时, + 尝试自进程系统级 AX 焦点解析(需 TCC 信任);若未信任或未解析出行,事件穿透至聚焦行自身 + `.onKeyPress(.return)` 兜底换页。 + ④ 焦点显式高亮:行获焦(任意行)且窗口处于活跃态(`controlActiveState == .key`)时展示 accent + 描边与浅灰底,窗口失焦切至后台时描边自动隐去。选区跟随方向键同步移动。 + + --- ## 8. 常用命令 diff --git a/docs/GUI-ARCHITECTURE.md b/docs/GUI-ARCHITECTURE.md index 826a010..c78286c 100644 --- a/docs/GUI-ARCHITECTURE.md +++ b/docs/GUI-ARCHITECTURE.md @@ -373,14 +373,94 @@ helper 缺失或版本不匹配时,界面给一个「安装 / 更新特权组 Transaction / transitions 两篇)—— 唯一「完全不插值」的环境是带 nil 动画的 下一拍事务,即第 2 条。 -**不变式**:改侧栏选中的新代码路径**必须走 `select()`**(保持跳出激活拍), +**不变式**:视图侧改侧栏选中的新代码路径**必须走 `select()`**(保持跳出激活拍), 不许在激活事务内直接写 `storedPage` —— 每多一个换页入口,这条都得跟着保持。 +唯一例外是窗口认领路径(`SidebarMainWindow` 的方向键与 Return 认领):窗口层 +拿不到 shell 的模型,经 `ButtonTurn.write` 直写存储键,且刻意不复锚 AX 焦点 +(不调 `syncAXFocus`)—— 同为跳拍写,与该不变式语义一致。 窗口侧:delegate 的 `windowDidBecomeMain` / `windowDidResize` 幂等重新钉 min/max —— 实测 macOS 26 窗口展示后不久会把 min/max **重置为未设**,伴随一次 +32pt 的 chrome settle(调用栈 `_setFrameCommon:display:fromServer:`); `styleMask` 从不含 `.resizable`(min/max 只防程序化改动),幂等写法同时防 `windowDidResize` 自递归。 +**无障碍按压(VoiceOver 的 AXPress)**:按压轮内 `NSApp.currentEvent == nil`, +且 macOS 26.5 会把该轮内排进 `DispatchQueue.main.async` / +`Task { @MainActor }` 的块**全部丢弃**(正文不跑),同步写则存活。所以 +`select()` 按 `NSApp.currentEvent` 的事件类型分流:鼠标/键盘轮内维持 +「跳出拍再写」的本节铁律,无障碍按压轮直接同步写。键盘轮内直写还有第二个 +坑:换页重渲染会把同一按键事件重新派发,一次按键双步进页。完整排查记录 +见 AGENTS.md 第 7 节坑 24。 + +**键盘(激活管线,round 6)**:real FKA 的 Space 与方向键被 +universalaccessd 在 HID 层拦截(合成的 NSEvent 到不了那层),Space +转成对焦点行的进程外 AXPress —— 落到行 +`.accessibilityAction(.default)`,并由 `ButtonTurn.writeDirect` +直写(进程外 AX 轮次里残留的 `NSApp.currentEvent` 会骗跳拍,被 +26.5 丢弃);Return 原样通过,在 `SidebarMainWindow` +的 `performKeyEquivalent` 拦截(条件:辅助功能面板 FKA 开 +(`SelfAccessibility.isFullKeyboardAccessOn`,真值勘误见坑 24 +round 7)、自进程系统级 AX 焦点解析出行标题、field editor 未激活), +未匹配时穿透至聚焦行自身的 `.onKeyPress(.return)` 兜底。 +`NSApp.isFullKeyboardAccessEnabled` 读的是旧偏好 +`AppleKeyboardUIMode`(键盘导航),不是辅助功能面板的 FKA 开关, +面板开关关掉时行上残留的 AX 焦点会让裸 Space/Return 静默跳页 +(round 7 真机回归)。方向键:侧栏在 `performKeyEquivalent` +先行认领裸方向键直切页面(条件 FKA-off、第一响应者非文本输入框; +`.function` 位不算和弦,禁令须是显式修饰键集合),认领后容器 +不再二次步进;容器保留方向键 `.onKeyPress` 作为冒泡兜底;FKA-on 时方向键归 HID 层。其余细节: +`ButtonTurn.write`,LogCard 的层级筛选与清空按钮走同一条路,点击 +驱动的换筛选/清空不再带激活事务的插值。 + +**焦点应答同步与自愈探索(round 8,自愈逻辑已在 round 11 superseded)**:行的键盘参与按辅助功能面板真值 +门控(round 11 定型:每行声明 +`.focusable(SelfAccessibility.isFullKeyboardAccessOn || isSelected)`,容器不再挂 focusable); +`.focused`/`.accessibilityFocused` 双引擎逐行绑定,键盘引擎落点 +经 `onChange(of: focusedRow)` 同步为 AX 应答 —— 这是进程内挪 AX +焦点的唯一受支持通道,外部写 `kAXFocusedUIElement` 是 no-op(坑 +24 round 8 ①)。(历史探索 / superseded):Round 8 曾尝试在轮询重渲染后通过 +`.onChange(of: model.status.monotonicNanos)` 与 involvement gate 进行自读系统应答自愈。 +该机制在 Round 11 原生架构中已完全停用(移除相关自愈代码,焦点由 SwiftUI 原生引擎管理)。 +FKA-off 契约收窄为**仅方向键**(r8 曾用 `.focusable(false)`,后在 r9/r11 重构)。 +层级筛选从 segmented 改 `.menu`:segmented 在键盘导航与真 FKA 下按键无响应,`.menu` 的 +弹出菜单是键盘可操作的正统替代;弹出菜单运行在 +`NSEventTrackingRunLoopMode`,无头环境无法合成开合/提交(硬件 +清单项),菜单存在性以 CGWindowList 差分取证(坑 24 round 8 +④);筛选绑定改走 `ButtonTurn.routed`(`ButtonTurn` 一并移入 +TetherKitUIModel,commit 语义有单测)。 + +**Tab 重入侧栏与 Return 的行级后背(round 9,真机复检)**:用户真机 +复检推翻 r8 的 FKA-off「仅方向键」契约 —— 键盘导航下 Tab 永远摸不到 +侧栏(行被 `.focusable(false)` 请出 Tab 环),且只要有控件持有焦点, +窗口对方向键的认领前置失效、方向键搁浅。契约改为:**Tab 在两种模式 +下都可达侧栏;Space/Return 在 FKA-off 里对行保持 inert;方向键从行 +焦点继续切页**。三处落地:行 `.focusable(true)` 常量化(条件修饰链 +再少一处,AX 身份更稳;r8 探针矩阵的期望项「Tab 永不落行」从此作 +废,未来矩阵先改期望);行 Button 动作统一走 `rowSelect`,FKA-off +时按 `NSApp.currentEvent` 的类型分流 —— 键盘驱动的原生激活(现场 +keyDown)丢弃、鼠标事件放行(`currentEvent` 的读取有两处 —— 此处与 +`ButtonTurn.write` 的跳拍判定 —— 都只作否定/分流信号用:AXPress 轮 +会残留旧事件,见坑 24 round 6);窗口方向键 +认领在第一响应者非文本输入框时步进页面。Return 补行级后背:真机 FKA 下窗口认领依赖的 AX 自读解析不出 +行(合成探针全过 —— 无头环境绕不开 HID 层,这一环只能真机验), +聚焦行自己挂 `.onKeyPress(.return)`(FKA-on 门,直调 `rowSelect`, +零 AX-read 依赖);小键盘 Enter(keycode 76)仍只有窗口认领覆盖 +(SwiftUI `KeyPress.Key` 无小键盘变体)。真机复检清单随之更新: +FKA-off —— Tab 触达侧栏行、Space/Return inert、行焦点下方向键切页; +FKA-on —— Tab 后 Return 也换页。 + +**自驱键盘环探索(round 10,已在 round 11 废弃 / superseded)**:round 9 的「Tab 两种模式 +可达」在早期探索中曾误判 FKA-off 下侧栏行不可达,round 10 尝试了由 AppKit 截获 +裸 Tab、在 `AppModel.sidebarRingRow` 上维护自驱键盘环的方案。但该方案存在 AppKit +截获容易侵占内容按钮焦序、且引入复杂状态机的问题。**该自驱键盘环方案已在 round 11 被完整移除 +并废弃**(`sidebarRingRow`、`SidebarFocusBridge` 均已删除),最终定型为 round 11 的纯原生架构。 + +**原生焦点环与全态方向键(round 11,最终定型架构)**: +1. **Tab 循环双向贯通**:r10 尝试的 AppKit 层自驱截获 Tab 会侵占内容按钮的自然焦序,而纯 SwiftUI 下之前侧栏被跳过的根因是 `rows` 容器上绑定的 `.focusable(SelfAccessibility.isFullKeyboardAccessOn)` 在 FKA-off 时将侧栏整组阻断。移除容器 focusable,选中行在 FKA-off 下使用 `.focusable(isSelected)`(FKA-on 下各行均可聚焦),Tab 形成「侧栏选中项 ↔ 内容各按钮」的双向可达循环,Tab 遍寻内容各按钮后平滑回至侧栏。 +2. **方向键全态切页**:原 `handleArrowKey` 守卫要求 `firstResponder == nil || firstResponder === contentView`,一旦用户按下 Tab 导致内容按钮(如 `SwiftUIAppKitButton` / `KeyViewProxy`)获焦,方向键即被误拦。改以 `!isFirstResponderEditableText` 为放行条件:只要第一响应者不是文本输入框(需保留光标移动),无论焦点在侧栏还是内容按钮,上下方向键均随时步进选区换页,换页后焦点重置自愈,用户绝不被卡在当前页。容器 `.onKeyPress` 保留上下方向键作为冒泡兜底。 +3. **Return 认领与行级后背**:FKA-on 下,窗口 `performKeyEquivalent` 在第一响应者不是 field editor 时,尝试读取系统级 FKA 焦点轨道(`SelfAccessibility.systemWideFocusedDescription()`)。⚠️ **TCC 权限说明**:自进程 UIElement 检查免权限,但通过 `AXUIElementCreateSystemWide()` 读取跨进程系统级焦点元素受 macOS TCC(辅助功能)门禁控制;应用默认不弹窗索权,未授予辅助功能权限时 `AXIsProcessTrusted()` 为假,系统级读取直接静默返回 nil,事件自然穿透至聚焦行自身的 `.onKeyPress(.return)` 兜底换页(无需任何系统权限)。 +4. **视觉反馈与窗口失焦**:行获焦且窗口处于活跃态(`controlActiveState == .key`)时展示 1pt accent 描边与浅灰底,窗口切至后台时描边自动隐去;选区跟随方向键同步移动。 + **验证(无头,不靠肉眼)**:`NSEvent` mouseDown/mouseUp + `NSWindow.sendEvent` 合成点击走真实响应链(无需 Accessibility / 录屏权限); 对自己进程可用 `AXUIElement` 读按钮几何自检命中(自家进程无 TCC 弹窗),命中 diff --git a/gui/Sources/TetherKitApp/KeyboardProbe.swift b/gui/Sources/TetherKitApp/KeyboardProbe.swift new file mode 100644 index 0000000..20ab76c --- /dev/null +++ b/gui/Sources/TetherKitApp/KeyboardProbe.swift @@ -0,0 +1,370 @@ +#if DEBUG +import AppKit +import TetherKitUIModel + +/// Headless keyboard probe (DEBUG only; opt-in via +/// `TETHERKIT_SB_PROBE=walk|matrix|fka_on|full`; any other value +/// fails with exit 1 rather than falling back to a mode). +/// +/// Walks the keyboard contract through the REAL dispatch chain — +/// synthesized `NSEvent`s fed to `NSApp.sendEvent`, no private APIs, +/// no permissions — and samples, after each step: the persisted page +/// selection, the window first responder, and the AX answers (self +/// app-level and the round-11 system-wide track, plus on-screen +/// geometry). Every line is prefixed `PROBE` for easy capture. +/// +/// Modes: +/// +/// * `walk` — facts only: presses successive Tabs and records where +/// each lands. No assertions. (Round 10 — AGENTS.md pitfall 24 — used a +/// DEBUG style lever to walk each row style; that lever has since been +/// removed.) +/// * `matrix` — one pass per sidebar page: two Tabs (recorded), a +/// bare Down that must step to the next page, an Up that must step +/// back, then one Shift+Tab (recorded). +/// * `fka_on` — FKA forced on via `TETHERKIT_FKA_OVERRIDE=1`: one +/// Return then one Tab, recorded; only the override's liveness is +/// asserted. +/// * `full` — the FKA-off contract suite (FKA forced off via +/// `TETHERKIT_FKA_OVERRIDE=0`), round 11. Space and Return are sent before any Tab, so no Tab can +/// first park a content button under them (the run-1 hazard: Space +/// landing on a real button once hijacked the whole probe run): +/// +/// - bare Down arrow steps the page → round 7 claim +/// (the assert is inequality-only on this leg; matrix mode +/// owns the exact ±1 oracle for a bare Down) +/// - bare Space is inert → FKA-off +/// contract (no stale-row actuation) +/// - bare Return is inert → FKA-off +/// contract +/// - bare Tab leaves the selection unchanged → the self-driven +/// ring is REMOVED (round 11); the suite asserts selection +/// invariance only — Tab landings are sampled and printed +/// (role/title/frame), never asserted +/// - a second Tab is equally inert on selection → no state +/// - a Down after two Tabs steps the page → round 11 full-state arrows; +/// this leg asserts the EXACT next page in the sidebar order, not just +/// "it changed" (closes the double-step an inequality would let through) +/// - an Up then steps back → reverse round trip; net-zero +/// only: afterUp == afterDown proves reversibility, not per-key magnitude +/// (the leg above carries the exact step) +/// +/// Exits 0 on PASS (or walk completion), 1 on FAIL, 3 on the 20 s +/// watchdog, 4 if event synthesis failed. Armed once, from +/// `MainWindowManager.present`. +@MainActor +enum KeyboardProbe { + private static var armed = false + private static var failures: [String] = [] + + /// Raw `kVK_*` key codes for the probe's synthesized keyDowns. + private enum KeyCode { + static let downArrow: UInt16 = 125 + static let upArrow: UInt16 = 126 + static let space: UInt16 = 49 + static let returnKey: UInt16 = 36 + static let tab: UInt16 = 48 + } + + /// The private-use glyphs NSEvent delivers for the Down (0xF701) + /// and Up (0xF700) arrow keys — sent as the bare `characters:` of + /// the synthesized arrow keyDowns. + private static let downArrowGlyph = "\u{F701}" + private static let upArrowGlyph = "\u{F700}" + + /// Wait after arming before the first synthetic key: the main + /// window must be on screen and past its first SwiftUI render. + private static let initialSettleSeconds: Double = 0.8 + + static func armIfNeeded() { + guard !armed, + let mode = ProcessInfo.processInfo.environment["TETHERKIT_SB_PROBE"] else { return } + armed = true + print("PROBE armed mode=\(mode) fka=\(SelfAccessibility.isFullKeyboardAccessOn)") + scheduleWatchdog() + switch mode { + case "walk": + after(initialSettleSeconds) { + _ = observe("base") + walk(1) + } + case "matrix": + runMatrixSuite() + case "fka_on": + runFkaOnSuite() + case "full": + runFullSuite() + default: + failures.append("unknown TETHERKIT_SB_PROBE mode \(mode)") + finish() + } + } + + private static func runFkaOnSuite() { + if !SelfAccessibility.isFullKeyboardAccessOn { + failures.append("TETHERKIT_FKA_OVERRIDE did not force FKA on") + } + after(initialSettleSeconds) { + _ = observe("fka-on-base") + sendKey(keyCode: KeyCode.returnKey, characters: "\r") + after(0.3) { + _ = observe("fka-on-return") + sendKey(keyCode: KeyCode.tab, characters: "\t") + after(0.3) { + _ = observe("fka-on-tab") + finish() + } + } + } + } + + /// Tests keyboard behavior across all sidebar pages. + private static func runMatrixSuite() { + print("PROBE [matrix] starting multi-page matrix tests across all \(SidebarPage.allCases.count) pages...") + after(initialSettleSeconds) { + testPage(index: 0) + } + } + + private static func testPage(index: Int) { + let pages = SidebarPage.allCases + guard index < pages.count else { + print("PROBE [matrix] all \(pages.count) pages tested successfully!") + finish() + return + } + let page = pages[index] + print("\nPROBE [matrix] page \(page.rawValue) ---") + ButtonTurn.write { + UserDefaults.standard.set(page.rawValue, forKey: SidebarPage.storageKey) + } + after(0.3) { + let base = observe("matrix-\(page.rawValue)-base") + if base != page.rawValue { + failures.append("page select failed for \(page.rawValue)") + } + // Tab 1: landing sampled, not assumed + sendKey(keyCode: KeyCode.tab, characters: "\t") + after(0.25) { + _ = observe("matrix-\(page.rawValue)-tab1-sidebar") + // Tab 2: landing sampled, not assumed + sendKey(keyCode: KeyCode.tab, characters: "\t") + after(0.25) { + _ = observe("matrix-\(page.rawValue)-tab2-content") + // Down Arrow while inside content: must step to next page! + let expectedNext = pages[(index + 1) % pages.count].rawValue + sendKey(keyCode: KeyCode.downArrow, characters: downArrowGlyph) + after(0.25) { + let afterDown = observe("matrix-\(page.rawValue)-down-from-content") + if afterDown != expectedNext { + failures.append("down arrow from content on \(page.rawValue) expected \(expectedNext) but got \(afterDown)") + } + // Up Arrow: must step back to original page! + sendKey(keyCode: KeyCode.upArrow, characters: upArrowGlyph) + after(0.25) { + let afterUp = observe("matrix-\(page.rawValue)-up-back") + if afterUp != page.rawValue { + failures.append("up arrow back on \(page.rawValue) expected \(page.rawValue) but got \(afterUp)") + } + // Shift+Tab: backwards traversal + sendKey(keyCode: KeyCode.tab, characters: "\t", modifierFlags: .shift) + after(0.25) { + _ = observe("matrix-\(page.rawValue)-shifttab") + // Move to next page in matrix + testPage(index: index + 1) + } + } + } + } + } + } + } + + /// Number of Tabs the walk mode sends before finishing. + private static let walkTabCount = 4 + + /// Facts only: send one Tab, then observe where it landed; chain to + /// the next Tab instead of scheduling sibling timers. + private static func walk(_ step: Int) { + sendKey(keyCode: KeyCode.tab, characters: "\t") + after(0.35) { + _ = observe("tab\(step)") + if step < walkTabCount { + walk(step + 1) + } else { + finishWalkFacts() + } + } + } + + private static func runFullSuite() { + if SelfAccessibility.isFullKeyboardAccessOn { + failures.append("full suite requires FKA off (set TETHERKIT_FKA_OVERRIDE=0 or disable Full Keyboard Access in System Settings)") + } + after(initialSettleSeconds) { + // Capture the pinned selection BEFORE sending any key: the + // timer fires after the previous step's key already moved + // the page, so reading it inside the nested closure would + // compare the stepped state against itself (round-11 + // probe rewrite: first run failed exactly that way). + let base = observe("base") + sendKey(keyCode: KeyCode.downArrow, characters: downArrowGlyph) + after(0.3) { + let afterDown = observe("down") + if afterDown == base { + failures.append("bare down arrow did not step the page") + } + sendKey(keyCode: KeyCode.space, characters: " ") + after(0.3) { + let afterSpace = observe("space") + if afterSpace != afterDown { + failures.append("space actuated a row (must stay inert under fka-off)") + } + sendKey(keyCode: KeyCode.returnKey, characters: "\r") + after(0.3) { + let afterReturn = observe("return") + if afterReturn != afterDown { + failures.append("return actuated a row (must stay inert under fka-off)") + } + sendKey(keyCode: KeyCode.tab, characters: "\t") + after(0.3) { + let tab1 = observe("tab1") + if tab1 != afterDown { + failures.append( + "tab1 moved the selection (ring removed; tab must be a no-op)") + } + sendKey(keyCode: KeyCode.tab, characters: "\t") + after(0.3) { + let tab2 = observe("tab2") + if tab2 != afterDown { + failures.append( + "tab2 moved the selection (tab must stay a no-op)") + } + sendKey(keyCode: KeyCode.downArrow, characters: downArrowGlyph) + after(0.3) { + let down2 = observe("downAfterButton") + if down2 == tab2 { + failures.append("down arrow did not step page while button was focused") + } + // Round-5 review backlog (O4): the checks above are + // inequality-only, so a double-step that lands on the + // WRONG page still passes them. Assert the exact successor + // the same way matrix mode does, computed from the page two + // Tabs left the selection on (Tabs never move the selection). + let pages = SidebarPage.allCases + if let tabIndex = pages.firstIndex(where: { $0.rawValue == tab2 }) { + let expectedNext = pages[(tabIndex + 1) % pages.count].rawValue + if down2 != expectedNext { + failures.append("down arrow after two Tabs expected exact successor \(expectedNext), got \(down2)") + } + } else { + failures.append("tab2 selection \(tab2) is not a known sidebar page") + } + sendKey(keyCode: KeyCode.upArrow, characters: upArrowGlyph) + after(0.3) { + let afterUp = observe("upAfterButton") + if afterUp != afterDown { + failures.append("up arrow did not step back (expected \(afterDown), got \(afterUp))") + } + finish() + } + } + } + } + } + } + } + } + } + + // MARK: - plumbing + + private static func scheduleWatchdog() { + after(20) { + print("PROBE VERDICT: FAIL (watchdog timeout)") + exit(3) + } + } + + private static func finish() { + if failures.isEmpty { + print("PROBE VERDICT: PASS") + exit(0) + } else { + for failure in failures { + print("PROBE VERDICT: FAIL — \(failure)") + } + exit(1) + } + } + + /// Walk mode reports facts and always exits 0. + private static func finishWalkFacts() { + print("PROBE VERDICT: PASS (walk)") + exit(0) + } + + /// Samples the observables and returns the persisted selection. + /// + /// Additional facts printed for offline analysis: the window first + /// responder, the app-level AX answer (role / title / description + /// / on-screen frame — SwiftUI's own vend), and the SYSTEM-WIDE + /// answer (the Full Keyboard Access focus track the round-11 + /// Return claim reads). Geometry is printed alongside `win=` for + /// column math: a landing site can answer no AX attributes + /// (opaque AppKit key-view proxies — run-1 fact), but its frame + /// still says WHERE focus sits. + /// `x,y wxh` for a frame, for the `axFrame=` / `win=` columns. + private static func rectText(_ rect: CGRect) -> String { + "\(Int(rect.minX)),\(Int(rect.minY)) \(Int(rect.width))x\(Int(rect.height))" + } + + private static func observe(_ label: String) -> String { + let selection = UserDefaults.standard.string(forKey: SidebarPage.storageKey) ?? "-" + let firstResponder = NSApp.keyWindow?.firstResponder + let responder = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let role = SelfAccessibility.focusedRole() ?? "nil" + let title = SelfAccessibility.focusedTitle() ?? "nil" + let description = SelfAccessibility.focusedDescription() ?? "nil" + let frameText = SelfAccessibility.focusedFrame().map(rectText) ?? "nil" + let sysRole = SelfAccessibility.systemWideFocusedRole() ?? "nil" + let sysTitle = SelfAccessibility.systemWideFocusedTitle() ?? "nil" + let sysDesc = SelfAccessibility.systemWideFocusedDescription() ?? "nil" + let windowText = (NSApp.keyWindow?.frame).map(rectText) ?? "nil" + print("PROBE [\(label)] sel=\(selection) fr=\(responder) role=\(role) " + + "title=\(title) desc=\(description) axFrame=\(frameText) " + + "sysRole=\(sysRole) sysTitle=\(sysTitle) sysDesc=\(sysDesc) " + + "win=\(windowText)") + return selection + } + + private static func after(_ seconds: Double, _ body: @escaping @MainActor () -> Void) { + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { + MainActor.assumeIsolated { body() } + } + } + + /// Synthesizes a bare keyDown and feeds it through the real + /// dispatch chain. `sendEvent` puts the event through the window's + /// key-equivalent phase first, exactly like a hardware key. + private static func sendKey(keyCode: UInt16, + characters: String, + modifierFlags: NSEvent.ModifierFlags = []) { + guard let window = NSApp.keyWindow, + let event = NSEvent.keyEvent(with: .keyDown, + location: NSPoint.zero, + modifierFlags: modifierFlags, + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode) else { + failures.append("event synthesis failed (keyCode \(keyCode))") + exit(4) + } + NSApp.sendEvent(event) + } +} +#endif diff --git a/gui/Sources/TetherKitApp/MainWindowManager.swift b/gui/Sources/TetherKitApp/MainWindowManager.swift index e34d7e9..7ab0dd0 100644 --- a/gui/Sources/TetherKitApp/MainWindowManager.swift +++ b/gui/Sources/TetherKitApp/MainWindowManager.swift @@ -1,6 +1,163 @@ import AppKit import SwiftUI import TetherKitIPC +import TetherKitUIModel + +/// The main window. `performKeyEquivalent` is AppKit's window-level +/// key-equivalent phase: it runs BEFORE the raw key reaches the first +/// responder, so Return can be claimed for the sidebar with no +/// lifecycle bookkeeping (a local NSEvent monitor would need explicit +/// install/removal across the menu-bar show/hide cycles instead). +/// Return passes through FKA untouched (research round 6: Full +/// Keyboard Access converts Space and the arrows — never Return — to +/// accessibility commands), so this is the one reliable in-app site +/// for that key; Space's real-FKA path is the rows' own +/// `.accessibilityAction(.default)` instead. Round 11: the claim +/// resolves the row through the SYSTEM-WIDE focused element (the real +/// FKA focus track) — the app's own `kAXFocusedUIElement` answer was +/// retired as a claim source after the user hardware pass showed it +/// permanently answering the last-anchored page; the focused row also +/// carries a read-free `.onKeyPress` backstop (SidebarRow), which +/// never fires when the system-wide read resolves (the claim consumes +/// the key first), and covers the setups where it does not. + +private final class SidebarMainWindow: NSWindow { + /// Virtual keycodes (macOS `keyCode` values): Return and keypad + /// Enter activate under Full Keyboard Access; arrows step pages + /// with the pane toggle off. + private enum KeyCode { + static let returnKey = 36 + static let keypadEnter = 76 + static let downArrow = 125 + static let upArrow = 126 + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + // Bare keys only: modifier chords (⌘⏎ and friends) fall + // through to normal shortcut handling for everything below. + // (Arrows carry macOS's `.function` device-independent bit + // even with no keys held — that bit is deliberately NOT + // banned; the ban is an explicit set, probed r7. Note: .capsLock + // is included so that engaged Caps Lock defers navigation to + // standard system handling rather than trapping bare navigation). + let isActivationKey = event.keyCode == KeyCode.returnKey + || event.keyCode == KeyCode.keypadEnter + let isArrow = event.keyCode == KeyCode.downArrow + || event.keyCode == KeyCode.upArrow + guard isActivationKey || isArrow, + event.modifierFlags + .intersection([.capsLock, .shift, .control, .option, .command]).isEmpty + else { + return super.performKeyEquivalent(with: event) + } + if isArrow { + return handleArrowKey(event) + } + // Return actuation is Full Keyboard Access-only (see + // SelfAccessibility.isFullKeyboardAccessOn): with the pane + // toggle off, AX focus on a row is stale-browsing state and + // Return must keep its normal behavior everywhere. + guard SelfAccessibility.isFullKeyboardAccessOn else { + return super.performKeyEquivalent(with: event) + } + // Deliberately narrow: Return yields only to the system field + // editor (the round 6 claim condition). Checked first to avoid + // any synchronous AX call when typing in text fields. Arrows + // use the broader editable-text predicate in `isFirstResponderEditableText` + // because caret movement applies to any editable text view. + let fieldEditorActive = (firstResponder as? NSTextView)?.isFieldEditor == true + guard !fieldEditorActive else { + return super.performKeyEquivalent(with: event) + } + + // Claim the key when the SYSTEM's focused element — the + // Full Keyboard Access focus track, read system-wide — is one + // of the sidebar rows. Round 11: NOT the app's own + // `kAXFocusedUIElement` answer. That answer is our + // `@AccessibilityFocusState` binding, and the round-10 probe + // fact bites: the SwiftUI focus engine never fires for + // Tab-loop/FKA landings, so the binding only ever says what + // the last mouse click anchored (user hardware pass: Return + // on ANY row selected the last-clicked page, network). The + // app-level answer is a lie by construction; the system-wide + // read is the actual FKA focus track. + // + // Note: system-wide element reading requires Accessibility trust; + // if untrusted or if it resolves to nothing, the key passes + // through to the focused row's own `.onKeyPress(.return)` backstop + // (SidebarRow) — read-free, engine-fact only. + let (systemWideFocusedPage, claimDescription) = + SidebarPage.fromSystemWideFocusedDescription() +#if DEBUG + // The description the claim resolved against — captured in the + // destructuring above, not a second AX read — reported for the + // user hardware pass (probe conventions: `PROBE [return-claim]`). + if let page = systemWideFocusedPage { + print("PROBE [return-claim] sys-wide matched page=\(page.rawValue) " + + "desc=\(claimDescription ?? "nil")") + } else { + print("PROBE [return-claim] sys-wide matched nothing " + + "(desc=\(claimDescription ?? "nil")) " + + "- key passes through to row backstop") + } +#endif + if let page = systemWideFocusedPage { + // A real key event turn: the standard hop applies. The + // write goes straight to the storage key, which the + // shell's `@AppStorage` observes — but unlike the shell's + // `select`, it does not re-anchor AX focus (`syncAXFocus`). + ButtonTurn.write { + UserDefaults.standard.set(page.rawValue, forKey: SidebarPage.storageKey) + } + return true + } + return super.performKeyEquivalent(with: event) + } + + /// True when the first responder is any editable text view — the + /// system field editor or an editable `NSTextView` — whose arrow + /// keys belong to caret movement, so the arrow claim must yield + /// and pass the key through to it. + private var isFirstResponderEditableText: Bool { + if let text = firstResponder as? NSTextView { + return text.isFieldEditor || text.isEditable + } + return false + } + + /// Bare Arrow Up/Down stepping of the sidebar pages, claimed at the + /// key-equivalent phase — before any first-responder dispatch, and + /// therefore before AppKit's Keyboard Navigation key-nav engine. + /// + /// Claimed when the Accessibility-pane FKA toggle is off and the user + /// is not editing a text field. A focused button, card, or scroll + /// area does not consume arrow keys, so page stepping is always + /// available unless an active text editor owns the keys for caret + /// movement. + /// + /// Stepping wraps in line with the shell's `stepSelection`, and + /// the write goes through `ButtonTurn.write`. + private func handleArrowKey(_ event: NSEvent) -> Bool { + guard !SelfAccessibility.isFullKeyboardAccessOn else { + return super.performKeyEquivalent(with: event) + } + guard !isFirstResponderEditableText else { + return super.performKeyEquivalent(with: event) + } + let pages = SidebarPage.allCases + let currentRaw = UserDefaults.standard.string(forKey: SidebarPage.storageKey) + let current = pages.first(where: { $0.rawValue == currentRaw }) ?? pages[0] + let index = pages.firstIndex(of: current) ?? 0 + let nextIndex = event.keyCode == KeyCode.downArrow + ? (index + 1) % pages.count + : (index + pages.count - 1) % pages.count + ButtonTurn.write { + UserDefaults.standard.set(pages[nextIndex].rawValue, + forKey: SidebarPage.storageKey) + } + return true + } +} /// Owns the main window. /// @@ -14,6 +171,7 @@ import TetherKitIPC /// One window object, reused forever: the menu bar's "Open Main /// Window" re-shows it, the red close button hides it, and closing it /// drops the app back to menu-bar-only mode. + @MainActor final class MainWindowManager: NSObject { static let shared = MainWindowManager() @@ -43,6 +201,9 @@ final class MainWindowManager: NSObject { window?.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) model.windowDidAppear() +#if DEBUG + KeyboardProbe.armIfNeeded() +#endif } /// Whether the main window itself is on screen. The menu-bar panel @@ -65,9 +226,9 @@ final class MainWindowManager: NSObject { } private func makeWindow(_ model: AppModel) -> NSWindow { - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, - width: Design.Window.width, - height: Design.Window.height), + let window = SidebarMainWindow(contentRect: NSRect(x: 0, y: 0, + width: Design.Window.width, + height: Design.Window.height), styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView], backing: .buffered, defer: false) diff --git a/gui/Sources/TetherKitApp/SelfAccessibility.swift b/gui/Sources/TetherKitApp/SelfAccessibility.swift new file mode 100644 index 0000000..e551157 --- /dev/null +++ b/gui/Sources/TetherKitApp/SelfAccessibility.swift @@ -0,0 +1,220 @@ +import ApplicationServices +import Foundation + +/// Reads our own process's accessibility tree. +/// +/// An application may probe ITS OWN tree without any TCC permission — +/// the Accessibility trust boundary exists to gate other processes' +/// trees (and screen contents), not self-inspection. The same technique +/// the round-7 probe exercised (torn down with the verification), kept +/// as a runtime capability. +/// +/// Round 11: the Return claim in `SidebarMainWindow` reads the +/// SYSTEM-WIDE focus track (below) — the app-level answer is our own +/// `@AccessibilityFocusState` vend, which the focus engine never +/// updates (round-10 probe fact) and which therefore only ever +/// reported the last anchor. The app-level readers remain for the +/// headless keyboard probe's diagnostics. +enum SelfAccessibility { + +#if DEBUG + /// Probe override for the pane toggle + /// (`TETHERKIT_FKA_OVERRIDE=0|1`). Headless keyboard probes must + /// pin the pane state regardless of what System Settings currently + /// shows; DEBUG-only so shipped builds always read the real + /// preference. + private static var fullKeyboardAccessOverride: Bool? { + guard let raw = ProcessInfo.processInfo.environment["TETHERKIT_FKA_OVERRIDE"] else { + return nil + } + return raw == "1" + } +#endif + + /// The Accessibility pane's Full Keyboard Access toggle + /// (universalaccessd's `keyboardAccessEnabled` preference), read + /// fresh at every call. This is the system state the sidebar's + /// Space/Return actuation is keyed to — NOT + /// `NSApp.isFullKeyboardAccessEnabled`, which reports the legacy + /// `AppleKeyboardUIMode` bit (Keyboard ▸ Keyboard Navigation) and + /// stays `true` while the pane toggle is off (both probed on + /// macOS 26.5, round 7). Keyboard focus on a row without the pane + /// toggle is stale-browsing state (round 7), so plain Space/Return + /// anywhere must stay inert. + static var isFullKeyboardAccessOn: Bool { +#if DEBUG + if let forced = fullKeyboardAccessOverride { return forced } +#endif + return (CFPreferencesCopyAppValue("keyboardAccessEnabled" as CFString, + "com.apple.universalaccess" as CFString) as? Bool) ?? false + } + +#if DEBUG + /// The description attribute of the keyboard-focused element of our + /// own application, or `nil` when focus is unset or the focused + /// element carries no description. Synchronous, and only as + /// expensive as the self-AX round-trip. Probe-only: the sole + /// caller is `KeyboardProbe.observe`, which samples on a timer. + static func focusedDescription() -> String? { + guard let element = focusedElement() else { return nil } + return stringAttribute(kAXDescriptionAttribute, on: element) + } + + /// The AX role of the focused element — probe discriminator for + /// opaque AppKit key-view proxies (they answer neither role nor + /// title, SwiftUI buttons answer `AXButton` plus their title). + static func focusedRole() -> String? { + guard let element = focusedElement() else { return nil } + return stringAttribute(kAXRoleAttribute, on: element) + } + + /// The AX title of the focused element — probe discriminator (see + /// `focusedRole`). + static func focusedTitle() -> String? { + guard let element = focusedElement() else { return nil } + return stringAttribute(kAXTitleAttribute, on: element) + } + + /// The on-screen frame of the keyboard-focused element, or `nil` + /// when unfocused or geometry is unavailable. The travel companion + /// of the attribute readers: opaque key-view proxies answer no + /// role / title / description (run-1 fact), but their geometry + /// still tells WHERE focus sits — sidebar column vs page content. + /// Probe-only discriminator. + static func focusedFrame() -> CGRect? { + guard let element = focusedElement() else { return nil } + var positionRef: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, + kAXPositionAttribute as CFString, + &positionRef) == .success, + let positionValue = positionRef else { + return nil + } + var sizeRef: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, + kAXSizeAttribute as CFString, + &sizeRef) == .success, + let sizeValue = sizeRef else { + return nil + } + var point = CGPoint.zero + var size = CGSize.zero + guard CFGetTypeID(positionValue) == AXValueGetTypeID(), + AXValueGetValue(positionValue as! AXValue, .cgPoint, &point), + CFGetTypeID(sizeValue) == AXValueGetTypeID(), + AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) else { + return nil + } + return CGRect(origin: point, size: size) + } + + private static func focusedElement() -> AXUIElement? { + let app = AXUIElementCreateApplication(ProcessInfo.processInfo.processIdentifier) + var ref: CFTypeRef? + guard AXUIElementCopyAttributeValue(app, + kAXFocusedUIElementAttribute as CFString, + &ref) == .success, + let value = ref, + CFGetTypeID(value) == AXUIElementGetTypeID() else { + return nil + } + return value as! AXUIElement + } +#endif + + // MARK: - System-wide focus track (round 11) + + /// The keyboard-focused element as the SYSTEM reports it — read on + /// the system-wide element, and accepted ONLY when it belongs to + /// this process. This is the channel the Full Keyboard Access + /// focus track answers through: a Return press (which + /// universalaccessd passes through untouched, round 6) must + /// actuate what this read resolves, or nothing. + /// + /// Note on permissions: unlike inspecting our own application's + /// AX tree, querying `AXUIElementCreateSystemWide()` for + /// `kAXFocusedUIElement` is a cross-process system-wide read that + /// requires Accessibility trust (TCC). When untrusted (`!AXIsProcessTrusted()`), + /// this returns nil (avoiding `kAXErrorCannotComplete`), and the + /// window-level claim yields to the focused row's `.onKeyPress(.return)` + /// backstop. + /// + /// Deliberately NOT the app-level `kAXFocusedUIElement` answer: + /// that is the `@AccessibilityFocusState` vend (see the type doc). + /// The PID acceptance also keeps foreign elements out — if the + /// focused element lives in another process, this answers nil and + /// the key keeps its normal behavior. + private static func systemWideFocusedElementOfOwnProcess() -> AXUIElement? { + guard AXIsProcessTrusted() else { +#if DEBUG + print("PROBE [syswide-read] process untrusted for system-wide AX read (falling back to row backstop)") +#endif + return nil + } + let systemWide = AXUIElementCreateSystemWide() + var ref: CFTypeRef? + let result = AXUIElementCopyAttributeValue(systemWide, + kAXFocusedUIElementAttribute as CFString, + &ref) + guard result == .success else { +#if DEBUG + print("PROBE [syswide-read] error=\(result.rawValue)") +#endif + return nil + } + guard let value = ref, + CFGetTypeID(value) == AXUIElementGetTypeID() else { +#if DEBUG + print("PROBE [syswide-read] focus unset or not an AX element") +#endif + return nil + } + let element = value as! AXUIElement + var pid: Int32 = 0 + AXUIElementGetPid(element, &pid) + guard pid == ProcessInfo.processInfo.processIdentifier else { +#if DEBUG + print("PROBE [syswide-read] foreign pid=\(pid)") +#endif + return nil + } + return element + } + + /// The description attribute of the system's keyboard-focused + /// element when it sits in our own process, or `nil`. Synchronous; + /// callers gate it behind a key event, so the one AX round-trip is + /// acceptable. + static func systemWideFocusedDescription() -> String? { + guard let element = systemWideFocusedElementOfOwnProcess() else { return nil } + return stringAttribute(kAXDescriptionAttribute, on: element) + } + +#if DEBUG + /// The AX role of the system's focused element — probe + /// discriminator (see `focusedRole`). + static func systemWideFocusedRole() -> String? { + guard let element = systemWideFocusedElementOfOwnProcess() else { return nil } + return stringAttribute(kAXRoleAttribute, on: element) + } + + /// The AX title of the system's focused element — probe + /// discriminator (see `focusedRole`). + static func systemWideFocusedTitle() -> String? { + guard let element = systemWideFocusedElementOfOwnProcess() else { return nil } + return stringAttribute(kAXTitleAttribute, on: element) + } +#endif + + private static func stringAttribute(_ attribute: String, on element: AXUIElement) -> String? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, + attribute as CFString, + &value) == .success, + let text = value as? String, + !text.isEmpty else { + return nil + } + return text + } +} diff --git a/gui/Sources/TetherKitApp/Views/LogCard.swift b/gui/Sources/TetherKitApp/Views/LogCard.swift index be99a9b..aaa47ac 100644 --- a/gui/Sources/TetherKitApp/Views/LogCard.swift +++ b/gui/Sources/TetherKitApp/Views/LogCard.swift @@ -1,5 +1,6 @@ import SwiftUI import TetherKitIPC +import TetherKitUIModel /// Log panel. /// @@ -33,18 +34,28 @@ struct LogCard: View { } } + /// The menu-style picker mutates the filter inside its activation + /// turn; the write routes through ButtonTurn so the list (and the + /// dropped-notice line) swap without interpolation. Same rationale + /// as SidebarShell's select(): AGENTS.md pitfalls 22 / 24. + private var levelFilterBinding: Binding { + ButtonTurn.routed( + get: { model.logLevelFilter }, + set: { model.logLevelFilter = $0 }) + } + private var toolbar: some View { HStack(spacing: Design.Spacing.small) { - Picker(L(.logLevelLabel), selection: $model.logLevelFilter) { + Picker(L(.logLevelLabel), selection: levelFilterBinding) { Text(L(.logLevelAll)).tag(LogLevel.trace) Text(L(.logLevelDebug)).tag(LogLevel.debug) Text(L(.logLevelInfo)).tag(LogLevel.info) Text(L(.logLevelWarning)).tag(LogLevel.warning) Text(L(.logLevelError)).tag(LogLevel.error) } - .pickerStyle(.segmented) + .pickerStyle(.menu) .labelsHidden() - .frame(maxWidth: 280) + .frame(maxWidth: 280, alignment: .leading) Spacer() @@ -62,12 +73,20 @@ struct LogCard: View { .help(L(.logCopyAll)) Button { - model.clearLogs() + // Clearing the list is a big membership change; inside + // the activation turn it would fade every row out + // (pitfall 22). Same escape as the filter binding. + ButtonTurn.write { model.clearLogs() } } label: { Image(systemName: "trash") } .buttonStyle(.borderless) .help(L(.logClear)) + .accessibilityAction(.default) { + // Direct write for AXPress (VoiceOver / accessibility turn): + // avoid async runloop hop dropped on macOS 26.5. + ButtonTurn.writeDirect { model.clearLogs() } + } } } diff --git a/gui/Sources/TetherKitApp/Views/NetworkCard.swift b/gui/Sources/TetherKitApp/Views/NetworkCard.swift index a89e4b1..55b790b 100644 --- a/gui/Sources/TetherKitApp/Views/NetworkCard.swift +++ b/gui/Sources/TetherKitApp/Views/NetworkCard.swift @@ -1,6 +1,7 @@ import SwiftUI import TetherKitCore import TetherKitIPC +import TetherKitUIModel /// 上网方式配置:DHCP 或静态 IP。 /// @@ -77,14 +78,26 @@ struct NetworkCard: View { } } + private var modeBinding: Binding { + ButtonTurn.routed( + get: { model.networkConfiguration.mode }, + set: { model.networkConfiguration.mode = $0 } + ) + } + private var modePicker: some View { - Picker(L(.ipModeLabel), selection: $model.networkConfiguration.mode) { - // 刻意不把「不配置」放进选择器:它是一个动作(撤销),不是一种上网 - // 方式。混在一起会让人以为选中它就已经生效了。 + Picker(L(.ipModeLabel), selection: modeBinding) { + // Deliberately keep "Not configured" out of the picker: it + // is an action (revert), not a network mode. Mixed in, it + // would look like selecting it has already taken effect. Text(IPMode.dhcp.displayName).tag(IPMode.dhcp) Text(IPMode.manual.displayName).tag(IPMode.manual) } - .pickerStyle(.segmented) + // Segmented pickers are keyboard-inert under Keyboard + // Navigation and the Accessibility pane's FKA toggle; `.menu` + // is the keyboard-operable replacement (AGENTS.md pitfall 24, + // round 8). Same style choice as LogCard's level filter. + .pickerStyle(.menu) .labelsHidden() } diff --git a/gui/Sources/TetherKitApp/Views/SidebarShell.swift b/gui/Sources/TetherKitApp/Views/SidebarShell.swift index 6d36f6e..7633ddc 100644 --- a/gui/Sources/TetherKitApp/Views/SidebarShell.swift +++ b/gui/Sources/TetherKitApp/Views/SidebarShell.swift @@ -24,36 +24,121 @@ struct SidebarShell: View { /// Persisted selection, restored on launch. `@AppStorage` because /// the AppKit-owned window has no scene for `@SceneStorage`. - @AppStorage("tetherkit.sidebar.page") private var storedPage: String = "" + @AppStorage(SidebarPage.storageKey) private var storedPage: String = "" + + /// Keyboard-focus engine state for the row group. Under Full + /// Keyboard Access the rows participate, and whatever the engine + /// advances to is where AX focus must answer — AGENTS.md pitfall 24 + /// round 8. + @FocusState private var focusedRow: SidebarPage? + /// The accessibility-focus engine state: writing it posts + /// `kAXFocusedUIElementChanged`, so the app's answer to + /// `kAXFocusedUIElement` tracks what this shell writes (the user's + /// selection). This is the ONLY sanctioned way to move AX focus + /// in-process — the outside-facing `AXUIElementSetAttributeValue` + /// write is a confirmed no-op on our own element (AGENTS.md pitfall 24 + /// round 8; probe-confirmed). Round 11: no key claim reads this vend + /// any more; the window's Return claim reads the system-wide focus + /// track. + @AccessibilityFocusState private var axFocusedRow: SidebarPage? private var selection: SidebarPage { SidebarPage.resolveStored(storedPage) } + init(model: AppModel) { + self.model = model +#if DEBUG + // Keyboard-probe pin (`TETHERKIT_SB_SELECT=`): the + // probe must not depend on the persisted selection — real pages + // carry destructive buttons a stray synthetic Space would fire. + if let pinned = ProcessInfo.processInfo.environment["TETHERKIT_SB_SELECT"], + UserDefaults.standard.string(forKey: SidebarPage.storageKey) != pinned { + UserDefaults.standard.set(pinned, forKey: SidebarPage.storageKey) + } +#endif + } + + /// Re-aims the app's `kAXFocusedUIElement` vend answer at a row + /// after a selection, so the answered element is the one the user + /// just chose. + /// + /// Round 11: the vend is NOT a claim source any more — the window's + /// Return claim reads the system-wide focus track instead (the + /// engine never updates the vend; the user hardware pass showed a + /// vend-reading claim selecting the last-anchored page from every + /// row). What remains is honest bookkeeping: anything ASKING the + /// app for its focused element gets the row the user actually + /// chose, not a stale anchor. + /// + /// Window claim paths (the window's arrow / Return claims) bypass + /// this on purpose: they write the selection without re-anchoring, + /// so after such a switch the vend stays pinned to the pre-switch + /// row by design. + private func syncAXFocus(to page: SidebarPage) { + guard SelfAccessibility.isFullKeyboardAccessOn else { return } + axFocusedRow = page + } + private func select(_ page: SidebarPage) { - // Deliberately instant, clamped on three layers: + // Deliberately instant. The turn-conditional hop (mouse/key + // turns leave the animated activation transaction; accessibility + // presses write direct because macOS 26.5 drops every async + // block scheduled from that turn) and the instant transaction + // all live in ButtonTurn.write — AGENTS.md pitfalls 22 / 24. + // + // The sidebar adds two local clamps on top: // - // 1. The write hops out of the button-activation turn. macOS 26 - // wraps Button actions in an animated transaction for the - // press/label state, and the click context is the only place - // the residual page-insertion interpolation lived: timer-driven - // swaps through this same method are pixel-clean. A next-hop - // write lands in a fresh transaction exactly like the timer - // path. - // 2. The write itself carries an instant transaction - // (`animation = nil`, `disablesAnimations = true`) so any - // caller that skips the runloop hop still swaps clean. - // 3. content/pageBody attach .transition(.identity) so a page's + // 1. content/pageBody attach .transition(.identity) so a page's // enter/exit is never interpolated, whatever drives it. + // 2. In-page animations (LogCard auto-scroll, StatusHeroCard + // pulse) own their transactions and are untouched. Rows also + // bypass the system activation machinery entirely + // (InstantButtonStyle). // - // In-page animations (LogCard auto-scroll, StatusHeroCard pulse) - // own their transactions and are untouched. Rows also bypass the - // system activation machinery entirely (InstantButtonStyle). - var instant = Transaction() - instant.animation = nil - instant.disablesAnimations = true - DispatchQueue.main.async { - withTransaction(instant) { self.storedPage = page.rawValue } + // The window's arrow and Return claims do NOT go through + // `select` — they write the storage key directly. + ButtonTurn.write { + self.storedPage = page.rawValue + } + syncAXFocus(to: page) + } + + /// Accessibility press write (the row `.accessibilityAction` + /// target): the caller is an AX action turn, which must write + /// direct — see `ButtonTurn.writeDirect` and AGENTS.md pitfall 24. + private func selectDirect(_ page: SidebarPage) { + ButtonTurn.writeDirect { self.storedPage = page.rawValue } + syncAXFocus(to: page) + } + + /// The row Button's plain activation entry. + /// + /// Under FKA every turn actuates — mouse clicks, native keyboard + /// activation showed up through the focus engine, and the AXPress + /// path (which enters via `selectDirect`, never here). With the + /// Accessibility pane's FKA toggle off, a key-driven turn is + /// dropped: the FKA-off keyboard surface of the sidebar is the + /// bare arrows only (window claim; the system key-view loop moves + /// focus across controls but must not activate rows without FKA). + /// Key-driven activation must stay inert while mouse clicks work. + /// + /// In this view, `NSApp.currentEvent` is read only behind the FKA-off + /// gate with a strict mouse allow-list: out-of-process accessibility + /// turns (e.g. AXPress) can leave a stale event from an earlier user + /// interaction, so key-driven activation is dropped. (Note: + /// `ButtonTurn.write` reads `currentEvent` independently to decide + /// the runloop hop). + private func rowSelect(_ page: SidebarPage) { + if SelfAccessibility.isFullKeyboardAccessOn { + select(page) + return + } + switch NSApp.currentEvent?.type { + case .leftMouseDown, .leftMouseUp: + select(page) + default: + break // inert: key-driven activation with the pane toggle off } } @@ -139,9 +224,22 @@ struct SidebarShell: View { /// The page rows. /// - /// The container is focusable so Up/Down arrows cycle pages - /// (`stepSelection`; the rows are hand-rolled buttons, so nothing - /// does this for us). The system's focus chrome is suppressed + /// The sidebar's keys are claimed at the WINDOW level + /// (`SidebarMainWindow.performKeyEquivalent`): bare arrows step the + /// pages with the Accessibility-pane FKA toggle off (round 11 + /// contract; with FKA on the arrows belong to the system focus + /// engine), and bare Return is resolved against the system-wide + /// focus track with FKA on (round 11). The rows ARE reachable + /// through the system key-view loop — each row declares + /// `.focusable(FKA || isSelected)` — so Tab lands on them in both + /// modes. + /// FKA Space actuates the row the focus engine landed on through + /// the row's `.accessibilityAction` — it arrives as an AXPress, + /// never an NSEvent (round 6). The container keeps arrow handlers + /// as a bubble-up fallback; Space/Return need no container path any + /// more (the vend answer they used to read was retired from the + /// claim in round 11). + /// The system's focus chrome is suppressed /// (`.focusEffectDisabled()`), the selected row's fill is the /// visible cursor. /// @@ -155,13 +253,15 @@ struct SidebarShell: View { SidebarRow( page: page, isSelected: selection == page, - iconTint: SidebarPage.tint(for: page, status: model.status) - ) { - select(page) - } + iconTint: SidebarPage.tint(for: page, status: model.status), + focusedRow: $focusedRow, + axFocusedRow: $axFocusedRow, + onSelect: { rowSelect(page) }, + onAccessibilityPress: { selectDirect(page) }, + onStep: stepSelection + ) } } - .focusable() .focusEffectDisabled() .onKeyPress(.upArrow) { stepSelection(forward: false) @@ -171,6 +271,20 @@ struct SidebarShell: View { stepSelection(forward: true) return .handled } + .onChange(of: storedPage) { _, newRaw in + // Re-anchor keyboard focus only when the engine already holds a row; + // never steal focus from content. Window-level arrow/Return claims + // write the storage key directly and must not pull focus back to the + // sidebar (AGENTS.md pitfall 24 round 11). + if focusedRow != nil, let page = SidebarPage(rawValue: newRaw) { + focusedRow = page + } + } + .onChange(of: focusedRow) { _, row in + guard SelfAccessibility.isFullKeyboardAccessOn, + let row, axFocusedRow != row else { return } + axFocusedRow = row + } } // MARK: - Content area @@ -283,11 +397,53 @@ private struct SidebarRow: View { let page: SidebarPage let isSelected: Bool let iconTint: Color + let focusedRow: FocusState.Binding + let axFocusedRow: AccessibilityFocusState.Binding let onSelect: () -> Void + /// Accessibility press path (AXPress: VoiceOver activation, Full + /// Keyboard Access Space). Writes direct — see the + /// `.accessibilityAction(.default)` attachment below and + /// `ButtonTurn.writeDirect`. + let onAccessibilityPress: () -> Void + /// Arrow-press handler for the focused row; `forward` = Down. + let onStep: (Bool) -> Void + /// Partial-strength accent for the key-focus outline. + private static let keyFocusRingOpacity = 0.55 + + @Environment(\.controlActiveState) private var controlActiveState @State private var isHovering = false + private var hasKeyboardFocus: Bool { + focusedRow.wrappedValue == page && controlActiveState == .key + } + var body: some View { + rowButton + .onHover { isHovering = $0 } + .focusable(SelfAccessibility.isFullKeyboardAccessOn || isSelected) + .focused(focusedRow, equals: page) + .accessibilityFocused(axFocusedRow, equals: page) + .accessibilityAddTraits(isSelected ? .isSelected : []) + .accessibilityAction(.default) { + onAccessibilityPress() + } + .onKeyPress(.upArrow) { + onStep(false) + return .handled + } + .onKeyPress(.downArrow) { + onStep(true) + return .handled + } + .onKeyPress(.return) { + guard SelfAccessibility.isFullKeyboardAccessOn else { return .ignored } + onSelect() + return .handled + } + } + + private var rowButton: some View { Button(action: onSelect) { HStack(spacing: Design.Spacing.small) { icon @@ -300,11 +456,15 @@ private struct SidebarRow: View { .padding(.horizontal, 8) .padding(.vertical, 7) .background(rowBackground, in: RoundedRectangle(cornerRadius: 7)) + .overlay { + if hasKeyboardFocus { + RoundedRectangle(cornerRadius: 7) + .strokeBorder(Color.accentColor.opacity(Self.keyFocusRingOpacity), lineWidth: 1) + } + } .contentShape(Rectangle()) } .buttonStyle(InstantButtonStyle()) - .onHover { isHovering = $0 } - .accessibilityAddTraits(isSelected ? .isSelected : []) } private var icon: some View { @@ -320,7 +480,10 @@ private struct SidebarRow: View { if isSelected { return Color.primary.opacity(0.08) } - return isHovering ? Color.primary.opacity(0.04) : Color.clear + if hasKeyboardFocus || isHovering { + return Color.primary.opacity(0.04) + } + return .clear } } @@ -338,3 +501,36 @@ private struct InstantButtonStyle: PrimitiveButtonStyle { .simultaneousGesture(TapGesture().onEnded { configuration.trigger() }) } } + +/// The persisted-selection key, shared by the shell's `@AppStorage` +/// and the window-level Return handler in MainWindowManager — one +/// literal, two writers of the same default. +extension SidebarPage { + static let storageKey = "tetherkit.sidebar.page" + + /// The page whose row the SYSTEM currently reports as the keyboard + /// focused element — read on the system-wide element and accepted + /// only for our own process — or `nil` when focus is unset, sits + /// outside this process, or is a non-row element. + /// + /// Round 11: this replaced `fromAXFocusAnswer` as the window's + /// Return-claim resolver. The app-level answer is our own + /// `@AccessibilityFocusState` vend: the SwiftUI focus engine does + /// not drive it (round-10 probe fact — `@FocusState` never fires + /// for FKA/loop landings), so the vend only ever reported the last + /// anchor, and the user hardware pass showed a claim built on it + /// selecting the same page from every row. The system-wide track + /// is the actual Full Keyboard Access focus; if it resolves + /// nothing, the claim passes the key through to the focused row's + /// `.onKeyPress` backstop. Resolution reads the focused element's + /// AX description and matches it against each page's localized + /// title — the row buttons vend their title as the description. + /// + /// The matched description is handed back alongside the page so the + /// caller's DEBUG report can print what this resolution actually + /// matched against without issuing a second system-wide AX read. + static func fromSystemWideFocusedDescription() -> (page: SidebarPage?, description: String?) { + guard let text = SelfAccessibility.systemWideFocusedDescription() else { return (nil, nil) } + return (allCases.first { $0.title == text }, text) + } +} diff --git a/gui/Sources/TetherKitUIModel/ButtonTurn.swift b/gui/Sources/TetherKitUIModel/ButtonTurn.swift new file mode 100644 index 0000000..139b027 --- /dev/null +++ b/gui/Sources/TetherKitUIModel/ButtonTurn.swift @@ -0,0 +1,119 @@ +import AppKit +import SwiftUI + +/// Escape hatch for state writes that run inside a control-activation +/// turn. +/// +/// macOS 26 wraps the view updates caused by activating a button, +/// picker segment, or toggle in an **animated transaction** (the +/// "activation turn"), so a state write in the action closure +/// interpolates: pages zoom, rows fade in and out, heights breathe. +/// Timer/polling-driven writes never run in such a turn and are +/// pixel-clean as-is — only activation-driven writes need this escape. +/// +/// Two runtime behaviors (probed on macOS 26.5 and bisected in +/// standalone repros; AGENTS.md pitfalls 22 / 24, +/// docs/GUI-ARCHITECTURE.md 4.11) shape the cure: +/// +/// * Mouse-click and key-press turns: the write must *leave* the +/// turn — hop to the next runloop tick, then write with an instant +/// transaction. A direct write during a key event re-renders +/// mid-event and re-delivers the same key (the sidebar +/// double-stepped its selection that way). +/// * Accessibility press turns (VoiceOver's AXPress et al.): every +/// asynchronous block scheduled from that turn is dropped before +/// its body runs — `DispatchQueue.main.async` and +/// `Task { @MainActor }` alike — and `NSApp.currentEvent` is not a +/// reliable discriminator there: it can be nil, but out-of-process +/// AX turns leave a stale event from an earlier click (AGENTS.md +/// pitfall 24 round 6). Such turns must route through +/// `writeDirect`, never through the hop. +/// +/// The write itself always carries an instant `Transaction`, so +/// whatever turn the body lands in, the flush interpolates nothing. +/// +/// Lives in this target (the sidebar page model) so both its callers +/// and its commit semantics are unit-testable: the App target is an +/// executable, which `swift test` cannot import (see TetherKitUIModelTests). +public enum ButtonTurn { + + /// Event types that identify an activation turn requiring the + /// runloop hop. `NSApp.currentEvent` is `nil` outside user turns + /// (programmatic writes therefore land direct), but accessibility + /// press turns are not reliably nil — AppKit can leave a stale + /// event (pitfall 24 round 6) — so those route through + /// `writeDirect`, never through this nil check. + private static let hopTurns: Set = [ + .leftMouseDown, .leftMouseUp, + .rightMouseDown, .rightMouseUp, + .otherMouseDown, .otherMouseUp, + .keyDown, .keyUp, + ] + + /// Runs `body` in a fresh instant transaction (`animation = nil`, + /// `disablesAnimations = true`), so nothing it changes is + /// interpolated. The transaction is built per call, not shared. + private static func withInstantTransaction(_ body: () -> Void) { + var instant = Transaction() + instant.animation = nil + instant.disablesAnimations = true + withTransaction(instant) { body() } + } + + /// Pluggable event type provider for testing the runloop hop branch + /// outside an interactive AppKit environment. Defaults to + /// `NSApp?.currentEvent?.type`. + internal static var currentEventTypeProvider: () -> NSEvent.EventType? = { + NSApp?.currentEvent?.type + } + + /// Runs `mutation` cleanly out of the current activation turn. + /// + /// Must run on the main thread: the type carries no `@MainActor`, + /// so caller discipline is the contract. It reads + /// `currentEventTypeProvider()` and schedules on the main queue. Callers + /// are control action / binding setters, which are main-thread by + /// construction. `NSApp` is nil before the application object + /// exists (notably in unit tests) — treated as no turn and the + /// write lands direct, which is what those tests assert. + public static func write(_ mutation: @escaping () -> Void) { + let body = { withInstantTransaction { mutation() } } + if let type = currentEventTypeProvider(), hopTurns.contains(type) { + DispatchQueue.main.async(execute: body) + } else { + body() + } + } + + /// Direct-write variant for accessibility action turns. + /// + /// Remote actuation (VoiceOver, Full Keyboard Access's Space, any + /// out-of-process AX client) arrives as `AXUIElementPerformAction` + /// IPC, not as an `NSEvent` (research round 6). AppKit does not + /// clear `NSApp.currentEvent` for such turns, so the generic + /// `write` sees a stale event from an earlier click and hops to + /// the next runloop tick — where macOS 26.5 drops every + /// asynchronous block scheduled during an accessibility turn + /// (AGENTS.md pitfall 24). Such a write would silently vanish on + /// real hardware. Accessibility press turns must write direct; + /// that is what this entry point does. + public static func writeDirect(_ mutation: @escaping () -> Void) { + withInstantTransaction { mutation() } + } + + /// A `Binding` whose writes route through `write`. + /// + /// Factory for control bindings (picker selections, toggles) that + /// mutate state inside the control's activation turn; routing the + /// write through `write` escapes the turn, so the affected + /// subtrees swap without interpolation. The commit semantics are + /// this escape's whole contract and are asserted in + /// TetherKitUIModelTests: outside a user turn (where a unit test + /// runs, `NSApp.currentEvent == nil`) the write lands synchronously. + public static func routed(get: @escaping () -> T, + set: @escaping (T) -> Void) -> Binding { + Binding(get: get, set: { value in + write { set(value) } + }) + } +} diff --git a/gui/Tests/TetherKitUIModelTests/ButtonTurnTests.swift b/gui/Tests/TetherKitUIModelTests/ButtonTurnTests.swift new file mode 100644 index 0000000..bb7271a --- /dev/null +++ b/gui/Tests/TetherKitUIModelTests/ButtonTurnTests.swift @@ -0,0 +1,110 @@ +import XCTest +import TetherKitIPC +@testable import TetherKitUIModel + +/// Locks the activation-turn write escape's commit semantics — the +/// mechanics the log-level picker (and every future routed control +/// binding) rides on. +/// +/// What is asserted is the machine-provable half of the round 8 +/// verdict (AGENTS.md pitfall 24): "`.menu` picker is the canonical +/// keyboard-operable replacement": +/// the value that a picker's selection binding commits must land +/// synchronously in the model outside a user turn, and the direct +/// accessibility turn must never hop at all (pitfall 24). The other +/// half — that a real keyboard can open the popped menu and commit an +/// item — is not synthesizable headlessly (menu tracking runs in +/// NSEventTrackingRunLoopMode beyond every in-process event channel) +/// and is covered by the hardware checklist that ships with this +/// round's probe evidence. +final class ButtonTurnTests: XCTestCase { + + /// Stand-in for AppModel's `logLevelFilter` slot: the model side of + /// the binding under test. + private final class FilterModel { + var level: LogLevel = .info + } + + // MARK: Routed binding commit + + func testRoutedBindingCommitLandsInModel() { + let model = FilterModel() + let binding = ButtonTurn.routed( + get: { model.level }, + set: { model.level = $0 }) + + // A unit test runs outside any activation turn + // (NSApp.currentEvent == nil), so the routed write is direct + // and synchronous — exactly the condition the audit path + // relies on. + binding.wrappedValue = .warning + XCTAssertEqual(model.level, .warning) + + binding.wrappedValue = .error + XCTAssertEqual(model.level, .error) + } + + func testRoutedBindingGetReadsModel() { + let model = FilterModel() + model.level = .trace + let binding = ButtonTurn.routed( + get: { model.level }, + set: { model.level = $0 }) + XCTAssertEqual(binding.wrappedValue, .trace) + } + + override func tearDown() { + super.tearDown() + ButtonTurn.currentEventTypeProvider = { NSApp?.currentEvent?.type } + } + + // MARK: User turn hop vs direct execution + + func testWriteInUserTurnHopsToMainQueue() { + let model = FilterModel() + ButtonTurn.currentEventTypeProvider = { .leftMouseDown } + + let expectation = expectation(description: "hopped write lands on main queue") + ButtonTurn.write { + model.level = .warning + expectation.fulfill() + } + + // Must not have run synchronously: + XCTAssertEqual(model.level, .info, "write must hop rather than land synchronously during user event turn") + wait(for: [expectation], timeout: 2.0) + XCTAssertEqual(model.level, .warning) + } + + func testWriteDirectInUserTurnBypassesHop() { + let model = FilterModel() + ButtonTurn.currentEventTypeProvider = { .leftMouseDown } + + // writeDirect must land synchronously even inside an event turn (AXPress turn): + ButtonTurn.writeDirect { + model.level = .error + } + XCTAssertEqual(model.level, .error, "writeDirect must land synchronously without hopping") + } + + func testWriteOutsideUserTurnLandsSynchronously() { + let model = FilterModel() + ButtonTurn.currentEventTypeProvider = { nil } + + ButtonTurn.write { + model.level = .debug + } + XCTAssertEqual(model.level, .debug, "write outside user turn must land synchronously") + } + + // MARK: Direct accessibility-turn write + + func testDirectWriteLandsSynchronously() { + // Pitfall 24: a turn scheduled into an accessibility press is + // dropped before running, so the direct entry point must write + // in the calling turn — asserted here as synchronous landing. + let model = FilterModel() + ButtonTurn.writeDirect { model.level = .error } + XCTAssertEqual(model.level, .error) + } +}