From b02afcb8c7f962c88c6ea5edc8ebfbfd8822d8c9 Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:44:27 +0530 Subject: [PATCH 1/9] feat(gui): keyboard access for the sidebar When macOS Full Keyboard Access is disabled, the system focus engine skips sidebar rows, ignoring Tab, arrow keys, and Return. Track focus internally and resolve activation against active keyboard focus rather than stale mouse click state. --- gui/Sources/TetherKitApp/KeyboardProbe.swift | 337 ++++++++++++++++++ .../TetherKitApp/MainWindowManager.swift | 167 ++++++++- .../TetherKitApp/SelfAccessibility.swift | 210 +++++++++++ gui/Sources/TetherKitApp/Views/LogCard.swift | 27 +- .../TetherKitApp/Views/NetworkCard.swift | 21 +- .../TetherKitApp/Views/SidebarShell.swift | 251 +++++++++++-- gui/Sources/TetherKitUIModel/ButtonTurn.swift | 119 +++++++ .../ButtonTurnTests.swift | 110 ++++++ 8 files changed, 1199 insertions(+), 43 deletions(-) create mode 100644 gui/Sources/TetherKitApp/KeyboardProbe.swift create mode 100644 gui/Sources/TetherKitApp/SelfAccessibility.swift create mode 100644 gui/Sources/TetherKitUIModel/ButtonTurn.swift create mode 100644 gui/Tests/TetherKitUIModelTests/ButtonTurnTests.swift diff --git a/gui/Sources/TetherKitApp/KeyboardProbe.swift b/gui/Sources/TetherKitApp/KeyboardProbe.swift new file mode 100644 index 0000000..e54a8bb --- /dev/null +++ b/gui/Sources/TetherKitApp/KeyboardProbe.swift @@ -0,0 +1,337 @@ +#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 runs +/// the FKA-off contract suite). +/// +/// 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 +/// - bare Space is inert → FKA-off +/// contract (no stale-row actuation) +/// - bare Return is inert → FKA-off +/// contract +/// - bare Tab changes nothing in the sidebar → the self-driven +/// ring is REMOVED (round 11): the system key-view loop cannot +/// reach the rows, so Tab must leave the selection alone +/// - a second Tab is equally inert → no state +/// +/// 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() + default: + runFullSuite() + } + } + + 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") + } + 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..e60f17b 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.) + 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 " + + "(role=\(SelfAccessibility.systemWideFocusedRole() ?? "nil") " + + "title=\(SelfAccessibility.systemWideFocusedTitle() ?? "nil") " + + "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..264d10f --- /dev/null +++ b/gui/Sources/TetherKitApp/SelfAccessibility.swift @@ -0,0 +1,210 @@ +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 AXValueGetValue(positionValue as! AXValue, .cgPoint, &point), + 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, + ref != nil else { + return nil + } + return ref 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, ref != nil else { +#if DEBUG + print("PROBE [syswide-read] error=\(result.rawValue)") +#endif + return nil + } + let element = ref 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..3bd62ea 100644 --- a/gui/Sources/TetherKitApp/Views/SidebarShell.swift +++ b/gui/Sources/TetherKitApp/Views/SidebarShell.swift @@ -24,36 +24,115 @@ 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. + 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 by its event type: the FKA-off keyboard surface of the + /// sidebar is the bare arrows only (window claim, round 7 + /// contract; the system key-view loop never reaches these rows — + /// round 10 probed every row style), so key-driven activation must + /// stay inert while mouse clicks work. + /// + /// `NSApp.currentEvent` is read ONLY here, only behind the FKA-off + /// gate, only as a negative signal: in out-of-process + /// accessibility turns it can hold a stale event (AGENTS.md + /// pitfall 24 round 6), so it must never gate actuation positively. + 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 +218,19 @@ 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 7 + /// contract — the system key-view loop cannot reach these rows, + /// round 10 probed every row style), and bare Return is resolved + /// against the system-wide focus track with FKA on (round 11). + /// 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 +244,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 +262,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 +388,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 +447,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 +471,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 +492,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) + } +} From 9e4653b98c32b0c35d32b38668d8b0b7db5888ff Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:44:39 +0530 Subject: [PATCH 2/9] docs: document sidebar keyboard navigation and verification steps Document keyboard focus behavior, headless probe verification, and release checks for sidebar keyboard navigation. --- AGENTS.md | 166 ++++++++++++++++++++++++++++++++++++++- docs/GUI-ARCHITECTURE.md | 82 +++++++++++++++++++ 2 files changed, 247 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 22d4ee1..9ed305a 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,170 @@ 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 时自进程读 + AX 焦点元素(`SelfAccessibility`,免权限)解析出是哪一行再 + `select()`。A/B 隔离证明该容器路径是唯一生效的激活机制(去掉它 + Space 就死)。已排除的两条路(勿重复施法):① 行按钮各自 + `.focusable()` 会把键盘事件拦进焦点引擎,容器的方向键处理器反而 + 收不到,方向键直接失灵;② `@FocusState` 程序化聚焦是静默 no-op, + AX 读回焦点为空,「指环随选区」走不通。另一个机器级事实:FKA + 开启后方向键被系统焦点引擎接管(「移动焦点」键),任何应用内 + 处理器都拦不住 —— 无 FKA 时容器方向键直切不受影响。键盘行为按 + 行标题匹配自限分流(round 6 起 `model.fullKeyboardAccess` 门已 + 移除,理由见下),实现见 `SidebarShell.activateFocusedRow` 与 + `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` Space/Return 保留为兜底。同时 + 移除了 `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 无关此开关)。② 键盘导航(`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` 整位掩码当和弦禁令会静默拒掉所有裸方向键(探针计数 + 证据:键进了 `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 覆写真值)下容器 Space 按焦点行切 + 换、Return 在焦点行上正确认领切换、文字视图安全;合成方向键 + 在 forced-UA 下仍经容器直切属无头混成态(真实 FKA-on 下 HID + 已吃掉 NSEvent,不会发生),不是缺陷。探针脚手架(AxProbe + .swift、fake-helper 钩子、DEBUG fkaOverride 与按键计数)随验 + 证一并拆除。 + Round 8(焦点应答、Tab 归属与选择器收官): + ① 进程内挪 AX 焦点的唯一受支持通道是 SwiftUI 状态包装: + `.focused`/`.accessibilityFocused` 逐行绑定、 + `onChange(of: focusedRow)` 粘合(写后者发 + `kAXFocusedUIElementChanged`);从外部对自己的 + `kAXFocusedUIElement` 做 `AXUIElementSetAttributeValue` 是 + 确认过的 no-op(err=0、焦点仍 nil,探针实证)。 + ② 轮询重渲染会使系统应答掉成 nil;恢复逻辑必须同步跑在更新环 + (`.onChange(of: model.status.monotonicNanos)`),`.task(id:)` + 的 sleep 在合成 run-loop 泵底下不复苏(探针实证:轮询循环在 + 泵窗内 6 拍即死、恢复永不触发);恢复按**系统应答**自读判定 + (自进程免权限)—— 按 SwiftUI 侧旧值布防的守卫在系统掉 nil + 后永不重振(R8H 连续 16 个采样全 nil 的教训);活应答按行标 + 题回填(早期探索曾记 `lastKnownRing`,后续在 round 11 已清理),掉空按它原位回锚、不往选区吸;探针 + 自身的 `makeFirstResponder(nil)` 也会清掉应答 —— 判 churn 前 + 先证 pre-answer,R8G 的「churn 掉焦点」结论实为探针自伤。 + ③ Forced-UA 的合成 Space 经容器管线以 `activateFocusedRow` + 着落,探针机器证明「激励落点 = 环行」;真实 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 归属用 `.focusable(false)` 请出行(探针 + 实证:Tab 落工具栏控件永不落行、Space/Return inert、 + axpress 照切),VoiceOver 走 AX 树与 `.accessibilityAction` + 不受影响。⑥ `ButtonTurn` 移入 TetherKitUIModel(可执行目 + 标不能被 `swift test` import),激活逃逸的 commit 语义有 + 4 条单测(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` 只在这一个位置、只作否定信号 —— 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` 保留上下方向键作为兜底。 + ③ 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..04a77b8 100644 --- a/docs/GUI-ARCHITECTURE.md +++ b/docs/GUI-ARCHITECTURE.md @@ -381,6 +381,88 @@ min/max —— 实测 macOS 26 窗口展示后不久会把 min/max **重置为 `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)**:行的键盘参与按辅助功能面板真值 +门控(`.focusable(SelfAccessibility.isFullKeyboardAccessOn)`); +`.focused`/`.accessibilityFocused` 双引擎逐行绑定,键盘引擎落点 +经 `onChange(of: focusedRow)` 同步为 AX 应答 —— 这是进程内挪 AX +焦点的唯一受支持通道,外部写 `kAXFocusedUIElement` 是 no-op(坑 +24 round 8 ①)。轮询重渲染可能撤销系统应答:恢复逻辑同步跑在 +`.onChange(of: model.status.monotonicNanos)`(异步 sleep 在合成 +泵底下不复苏,坑 24 round 8 ②),自读系统应答(早期探索曾尝试 +`lastKnownRing`,后续在 round 11 已清理)、应答掉空时按它原位回锚、不往选区吸;按绑定状 +态布防的守卫在系统已掉 nil 后永不重振,必须以系统应答为地真。 +侧栏从未参与过键盘流时不恢复(involvement gate)—— 免得与用户 +点进日志筛选框的瞬时 nil 竞态。FKA-off 契约收窄为**仅方向键**: +行 `.focusable(false)` 请出 Tab 环(探针实证 Tab 落工具栏控件、 +Space/Return inert、axpress 照切),VoiceOver 行走 AX 树与 +`.accessibilityAction` 不受影响。层级筛选从 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` 只在这一个位置、只作 +否定信号用:AXPress 轮会残留旧事件,见坑 24 round 6);窗口方向键 +认领新增第三条件 —— 自进程 AX 应答解析为某一行时也认领 +(round 9 早期曾使用 `fromAXFocusAnswer()` 自读,round 11 已被 `fromSystemWideFocusedDescription` 替换)。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 辅助功能权限控制);若无辅助功能权限或未匹配到行,事件自然放行至聚焦行自身的 `.onKeyPress(.return)` 兜底执行 `rowSelect`。 +4. **视觉反馈与窗口失焦**:行获焦且窗口处于活跃态(`controlActiveState == .key`)时展示 1pt accent 描边与浅灰底,窗口切至后台时描边自动隐去;选区跟随方向键同步移动。 + **验证(无头,不靠肉眼)**:`NSEvent` mouseDown/mouseUp + `NSWindow.sendEvent` 合成点击走真实响应链(无需 Accessibility / 录屏权限); 对自己进程可用 `AXUIElement` 读按钮几何自检命中(自家进程无 TCC 弹窗),命中 From 0f6cb6b5ae4a169aeb4693102a1b251e3407e1de Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:43:31 +0530 Subject: [PATCH 3/9] fix(gui): validate AX element types and trim debug AX reads Check CF type ID before downcasting to AXUIElement, eliminate redundant system-wide AX reads in debug probe logging, and document caps lock modifier handling. --- gui/Sources/TetherKitApp/KeyboardProbe.swift | 8 ++++---- .../TetherKitApp/MainWindowManager.swift | 8 ++++---- .../TetherKitApp/SelfAccessibility.swift | 11 +++++++---- .../TetherKitApp/Views/SidebarShell.swift | 19 ++++++++++--------- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/gui/Sources/TetherKitApp/KeyboardProbe.swift b/gui/Sources/TetherKitApp/KeyboardProbe.swift index e54a8bb..02d15dd 100644 --- a/gui/Sources/TetherKitApp/KeyboardProbe.swift +++ b/gui/Sources/TetherKitApp/KeyboardProbe.swift @@ -35,10 +35,10 @@ import TetherKitUIModel /// contract (no stale-row actuation) /// - bare Return is inert → FKA-off /// contract -/// - bare Tab changes nothing in the sidebar → the self-driven -/// ring is REMOVED (round 11): the system key-view loop cannot -/// reach the rows, so Tab must leave the selection alone -/// - a second Tab is equally inert → no state +/// - bare Tab moves focus, not selection → the self-driven +/// ring is REMOVED (round 11): Tab navigates between sidebar +/// and content buttons without mutating page selection +/// - a second Tab is equally inert on selection → no state /// /// Exits 0 on PASS (or walk completion), 1 on FAIL, 3 on the 20 s /// watchdog, 4 if event synthesis failed. Armed once, from diff --git a/gui/Sources/TetherKitApp/MainWindowManager.swift b/gui/Sources/TetherKitApp/MainWindowManager.swift index e60f17b..7ab0dd0 100644 --- a/gui/Sources/TetherKitApp/MainWindowManager.swift +++ b/gui/Sources/TetherKitApp/MainWindowManager.swift @@ -37,7 +37,9 @@ private final class SidebarMainWindow: NSWindow { // 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.) + // 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 @@ -95,9 +97,7 @@ private final class SidebarMainWindow: NSWindow { + "desc=\(claimDescription ?? "nil")") } else { print("PROBE [return-claim] sys-wide matched nothing " - + "(role=\(SelfAccessibility.systemWideFocusedRole() ?? "nil") " - + "title=\(SelfAccessibility.systemWideFocusedTitle() ?? "nil") " - + "desc=\(claimDescription ?? "nil")) " + + "(desc=\(claimDescription ?? "nil")) " + "- key passes through to row backstop") } #endif diff --git a/gui/Sources/TetherKitApp/SelfAccessibility.swift b/gui/Sources/TetherKitApp/SelfAccessibility.swift index 264d10f..3b4d54d 100644 --- a/gui/Sources/TetherKitApp/SelfAccessibility.swift +++ b/gui/Sources/TetherKitApp/SelfAccessibility.swift @@ -112,10 +112,11 @@ enum SelfAccessibility { guard AXUIElementCopyAttributeValue(app, kAXFocusedUIElementAttribute as CFString, &ref) == .success, - ref != nil else { + let value = ref, + CFGetTypeID(value) == AXUIElementGetTypeID() else { return nil } - return ref as! AXUIElement + return (value as! AXUIElement) } #endif @@ -153,13 +154,15 @@ enum SelfAccessibility { let result = AXUIElementCopyAttributeValue(systemWide, kAXFocusedUIElementAttribute as CFString, &ref) - guard result == .success, ref != nil else { + guard result == .success, + let value = ref, + CFGetTypeID(value) == AXUIElementGetTypeID() else { #if DEBUG print("PROBE [syswide-read] error=\(result.rawValue)") #endif return nil } - let element = ref as! AXUIElement + let element = value as! AXUIElement var pid: Int32 = 0 AXUIElementGetPid(element, &pid) guard pid == ProcessInfo.processInfo.processIdentifier else { diff --git a/gui/Sources/TetherKitApp/Views/SidebarShell.swift b/gui/Sources/TetherKitApp/Views/SidebarShell.swift index 3bd62ea..f60cd9c 100644 --- a/gui/Sources/TetherKitApp/Views/SidebarShell.swift +++ b/gui/Sources/TetherKitApp/Views/SidebarShell.swift @@ -113,16 +113,17 @@ struct SidebarShell: View { /// 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 by its event type: the FKA-off keyboard surface of the - /// sidebar is the bare arrows only (window claim, round 7 - /// contract; the system key-view loop never reaches these rows — - /// round 10 probed every row style), so key-driven activation must - /// stay inert while mouse clicks work. + /// 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. /// - /// `NSApp.currentEvent` is read ONLY here, only behind the FKA-off - /// gate, only as a negative signal: in out-of-process - /// accessibility turns it can hold a stale event (AGENTS.md - /// pitfall 24 round 6), so it must never gate actuation positively. + /// 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) From 251e879918f382cf528c593cc0f55ecd65ca7e21 Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:43:42 +0530 Subject: [PATCH 4/9] docs: clarify TCC accessibility permissions and align architecture notes Clarify cross-process system-wide AX read permissions under TCC, note superseded status of prior focus recovery explorations, and sync unit test counts. --- AGENTS.md | 55 ++++++++++++++++------------------------ docs/GUI-ARCHITECTURE.md | 23 ++++++----------- 2 files changed, 30 insertions(+), 48 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9ed305a..fd05c02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -681,19 +681,16 @@ TX 就从 4.8 回到了 87 Mbps。 `AXUIElementCreateApplication` 遍历 + `performAction` + AppStorage 键读回即可闭环复现。真人 VO/FKA 复核(2026-11-07):方向键与 `AXPress` 激活正常;Space/Return 确认为**真实缺陷**(真人按压同样 - 无效果,合成事件伪影的怀疑被排除)。现在已修:被聚焦的行自己收不到 - 按键事件,事件总会落到侧栏容器;容器收到 Space/Return 时自进程读 - AX 焦点元素(`SelfAccessibility`,免权限)解析出是哪一行再 - `select()`。A/B 隔离证明该容器路径是唯一生效的激活机制(去掉它 - Space 就死)。已排除的两条路(勿重复施法):① 行按钮各自 + 无效果,合成事件伪影的怀疑被排除)。(注:早期曾探索由侧栏容器拦截 + Space/Return 并调用 `activateFocusedRow`,该方案在 Round 11 已被完全重构为: + 行自身声明 `.accessibilityAction(.default)` 直写,窗口拦截 Return 认领 + 行级 + `.onKeyPress(.return)` 兜底换页)。已排除的两条路(勿重复施法):① 行按钮各自 `.focusable()` 会把键盘事件拦进焦点引擎,容器的方向键处理器反而 收不到,方向键直接失灵;② `@FocusState` 程序化聚焦是静默 no-op, AX 读回焦点为空,「指环随选区」走不通。另一个机器级事实:FKA 开启后方向键被系统焦点引擎接管(「移动焦点」键),任何应用内 - 处理器都拦不住 —— 无 FKA 时容器方向键直切不受影响。键盘行为按 - 行标题匹配自限分流(round 6 起 `model.fullKeyboardAccess` 门已 - 移除,理由见下),实现见 `SidebarShell.activateFocusedRow` 与 - `SelfAccessibility`; + 处理器都拦不住 —— 无 FKA 时容器方向键直切不受影响。实现见 `SidebarShell.rowSelect`、 + `SidebarRow` 与 `SelfAccessibility`; 跳拍/直写的公共出口在 `ButtonTurn.write`,LogCard 的层级筛选与 清空按钮同用一条路(点击筛选/清空不再带激活事务的插值)。 Round 6(三路系统级调研后定型真机激活管线):real FKA 的 Space @@ -709,8 +706,9 @@ TX 就从 4.8 回到了 87 Mbps。 异步块,直写是不依赖轮次判定的唯一写法;② Return / 小键盘 Enter 在 `SidebarMainWindow.performKeyEquivalent` 拦(AppKit 键 等价相位先于 firstResponder 派发,且免增加/卸载监听的生命周期 - 管理),条件是自进程 AX 焦点解析出行标题且 field editor 未激活, - 否则原样放行;③ 容器 `.onKeyPress` Space/Return 保留为兜底。同时 + 管理),条件是系统级 AX 焦点解析出行标题且 field editor 未激活, + 否则原样放行;③ 聚焦行自身の `.onKeyPress(.return)` 保留为兜底(Round 11 定型; + 容器仅处理方向键)。同时 移除了 `model.fullKeyboardAccess` 门(AppModel 的轮询与属性一并 回退):`NSApp.isFullKeyboardAccessEnabled` 读的是旧偏好 `AppleKeyboardUIMode`(Keyboard ▸ 键盘导航),不是辅助功能面板的 @@ -727,7 +725,7 @@ TX 就从 4.8 回到了 87 Mbps。 Enabled", "com.apple.universalaccess")`( `SelfAccessibility.isFullKeyboardAccessOn`,每次现读);实测机 上 `AppleKeyboardUIMode=2`(键盘导航开)与面板开关关长期并存, - 所以 Space/Return 两条按键管线(容器 `.onKeyPress` 与窗口 + 所以 Space/Return 两条按键管线(聚焦行 `.onKeyPress` 与窗口 `performKeyEquivalent`)都改挂这个真值为门 —— 面板开关关掉时 行上的 AX 焦点只是「上次浏览残留」(点行或 Tab 摸过后赖着 不走),裸 Space/Return 会静默重激活那一行(真机症状:哪里按 @@ -754,12 +752,11 @@ TX 就从 4.8 回到了 87 Mbps。 UA-off 下裸下/上方向键即换页(含环绕回卷)、裸 Space/Return 与「Tab 残留焦点 + Space/Return」全 inert、非选中行 AXPress 照切、植入 NSTextView 与分段控件持焦时三键全放行、⌘ 和弦 - 穿透;forced-UA-on(DEBUG 覆写真值)下容器 Space 按焦点行切 + 穿透;forced-UA-on(DEBUG 覆写真值)下行激活按焦点行切 换、Return 在焦点行上正确认领切换、文字视图安全;合成方向键 在 forced-UA 下仍经容器直切属无头混成态(真实 FKA-on 下 HID - 已吃掉 NSEvent,不会发生),不是缺陷。探针脚手架(AxProbe - .swift、fake-helper 钩子、DEBUG fkaOverride 与按键计数)随验 - 证一并拆除。 + 已吃掉 NSEvent,不会发生),不是缺陷。探针脚手架中 AxProbe.swift + 已在 r7 验证后拆除;DEBUG fkaOverride 与按键计数作为回归守卫保留。 Round 8(焦点应答、Tab 归属与选择器收官): ① 进程内挪 AX 焦点的唯一受支持通道是 SwiftUI 状态包装: `.focused`/`.accessibilityFocused` 逐行绑定、 @@ -767,17 +764,11 @@ TX 就从 4.8 回到了 87 Mbps。 `kAXFocusedUIElementChanged`);从外部对自己的 `kAXFocusedUIElement` 做 `AXUIElementSetAttributeValue` 是 确认过的 no-op(err=0、焦点仍 nil,探针实证)。 - ② 轮询重渲染会使系统应答掉成 nil;恢复逻辑必须同步跑在更新环 - (`.onChange(of: model.status.monotonicNanos)`),`.task(id:)` - 的 sleep 在合成 run-loop 泵底下不复苏(探针实证:轮询循环在 - 泵窗内 6 拍即死、恢复永不触发);恢复按**系统应答**自读判定 - (自进程免权限)—— 按 SwiftUI 侧旧值布防的守卫在系统掉 nil - 后永不重振(R8H 连续 16 个采样全 nil 的教训);活应答按行标 - 题回填(早期探索曾记 `lastKnownRing`,后续在 round 11 已清理),掉空按它原位回锚、不往选区吸;探针 - 自身的 `makeFirstResponder(nil)` 也会清掉应答 —— 判 churn 前 - 先证 pre-answer,R8G 的「churn 掉焦点」结论实为探针自伤。 - ③ Forced-UA 的合成 Space 经容器管线以 `activateFocusedRow` - 着落,探针机器证明「激励落点 = 环行」;真实 HID 仍属硬件 + ② (探索历史 / 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` 弹出式是键盘可操作的 @@ -787,12 +778,10 @@ TX 就从 4.8 回到了 87 Mbps。 窗口差分(layer>0、自 pid)取证,AXMenu 计数会被菜单栏基线 污染;对 `AXPopUpButton` 写 value 是幽灵成功(err=0、 `valueSettable=false`、值不变)。 - ⑤ FKA-off 的 Tab 归属用 `.focusable(false)` 请出行(探针 - 实证:Tab 落工具栏控件永不落行、Space/Return inert、 - axpress 照切),VoiceOver 走 AX 树与 `.accessibilityAction` - 不受影响。⑥ `ButtonTurn` 移入 TetherKitUIModel(可执行目 + ⑤ FKA-off 的 Tab 归属:r8 曾用 `.focusable(false)`,后在 r9/r11 重构。 + ⑥ `ButtonTurn` 移入 TetherKitUIModel(可执行目 标不能被 `swift test` import),激活逃逸的 commit 语义有 - 4 条单测(TetherKitUIModelTests/ButtonTurnTests)。 + 6 条单测(TetherKitUIModelTests/ButtonTurnTests)。 Round 9(真机复检推翻 r8 的 FKA-off Tab 契约): ① 键盘导航下 Tab 循环原不打进侧栏(行 `.focusable(false)`), 只要任何控件持有焦点,窗口方向键认领的「焦点为空/窗口自身」前置 @@ -827,7 +816,7 @@ TX 就从 4.8 回到了 87 Mbps。 ③ Return 认领与行级后背:窗口 `performKeyEquivalent` 在第一响应者非 field editor 时, 尝试自进程系统级 AX 焦点解析(需 TCC 信任);若未信任或未解析出行,事件穿透至聚焦行自身 `.onKeyPress(.return)` 兜底换页。 - ④ 焦点显式高亮:选中行获焦且窗口处于活跃态(`controlActiveState == .key`)时展示 accent + ④ 焦点显式高亮:行获焦(任意行)且窗口处于活跃态(`controlActiveState == .key`)时展示 accent 描边与浅灰底,窗口失焦切至后台时描边自动隐去。选区跟随方向键同步移动。 diff --git a/docs/GUI-ARCHITECTURE.md b/docs/GUI-ARCHITECTURE.md index 04a77b8..58e47c1 100644 --- a/docs/GUI-ARCHITECTURE.md +++ b/docs/GUI-ARCHITECTURE.md @@ -409,22 +409,16 @@ round 7)、自进程系统级 AX 焦点解析出行标题、field editor 未 `ButtonTurn.write`,LogCard 的层级筛选与清空按钮走同一条路,点击 驱动的换筛选/清空不再带激活事务的插值。 -**焦点应答同步与自愈(round 8)**:行的键盘参与按辅助功能面板真值 +**焦点应答同步与自愈探索(round 8,自愈逻辑已在 round 11 superseded)**:行的键盘参与按辅助功能面板真值 门控(`.focusable(SelfAccessibility.isFullKeyboardAccessOn)`); `.focused`/`.accessibilityFocused` 双引擎逐行绑定,键盘引擎落点 经 `onChange(of: focusedRow)` 同步为 AX 应答 —— 这是进程内挪 AX 焦点的唯一受支持通道,外部写 `kAXFocusedUIElement` 是 no-op(坑 -24 round 8 ①)。轮询重渲染可能撤销系统应答:恢复逻辑同步跑在 -`.onChange(of: model.status.monotonicNanos)`(异步 sleep 在合成 -泵底下不复苏,坑 24 round 8 ②),自读系统应答(早期探索曾尝试 -`lastKnownRing`,后续在 round 11 已清理)、应答掉空时按它原位回锚、不往选区吸;按绑定状 -态布防的守卫在系统已掉 nil 后永不重振,必须以系统应答为地真。 -侧栏从未参与过键盘流时不恢复(involvement gate)—— 免得与用户 -点进日志筛选框的瞬时 nil 竞态。FKA-off 契约收窄为**仅方向键**: -行 `.focusable(false)` 请出 Tab 环(探针实证 Tab 落工具栏控件、 -Space/Return inert、axpress 照切),VoiceOver 行走 AX 树与 -`.accessibilityAction` 不受影响。层级筛选从 segmented 改 -`.menu`:segmented 在键盘导航与真 FKA 下按键无响应,`.menu` 的 +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 @@ -442,8 +436,7 @@ TetherKitUIModel,commit 语义有单测)。 时按 `NSApp.currentEvent` 的类型分流 —— 键盘驱动的原生激活(现场 keyDown)丢弃、鼠标事件放行(`currentEvent` 只在这一个位置、只作 否定信号用:AXPress 轮会残留旧事件,见坑 24 round 6);窗口方向键 -认领新增第三条件 —— 自进程 AX 应答解析为某一行时也认领 -(round 9 早期曾使用 `fromAXFocusAnswer()` 自读,round 11 已被 `fromSystemWideFocusedDescription` 替换)。Return 补行级后背:真机 FKA 下窗口认领依赖的 AX 自读解析不出 +认领在第一响应者非文本输入框时步进页面。Return 补行级后背:真机 FKA 下窗口认领依赖的 AX 自读解析不出 行(合成探针全过 —— 无头环境绕不开 HID 层,这一环只能真机验), 聚焦行自己挂 `.onKeyPress(.return)`(FKA-on 门,直调 `rowSelect`, 零 AX-read 依赖);小键盘 Enter(keycode 76)仍只有窗口认领覆盖 @@ -460,7 +453,7 @@ FKA-on —— Tab 后 Return 也换页。 **原生焦点环与全态方向键(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 辅助功能权限控制);若无辅助功能权限或未匹配到行,事件自然放行至聚焦行自身的 `.onKeyPress(.return)` 兜底执行 `rowSelect`。 +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 + From 72e9c8e82e0fe21431c69887a9687bae142b01c6 Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:21:10 +0530 Subject: [PATCH 5/9] fix(gui): validate AXValue types and distinguish AX errors --- .../TetherKitApp/SelfAccessibility.swift | 17 ++++++++++++----- .../TetherKitApp/Views/SidebarShell.swift | 11 +++++++---- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/gui/Sources/TetherKitApp/SelfAccessibility.swift b/gui/Sources/TetherKitApp/SelfAccessibility.swift index 3b4d54d..e551157 100644 --- a/gui/Sources/TetherKitApp/SelfAccessibility.swift +++ b/gui/Sources/TetherKitApp/SelfAccessibility.swift @@ -99,7 +99,9 @@ enum SelfAccessibility { } var point = CGPoint.zero var size = CGSize.zero - guard AXValueGetValue(positionValue as! AXValue, .cgPoint, &point), + guard CFGetTypeID(positionValue) == AXValueGetTypeID(), + AXValueGetValue(positionValue as! AXValue, .cgPoint, &point), + CFGetTypeID(sizeValue) == AXValueGetTypeID(), AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) else { return nil } @@ -116,7 +118,7 @@ enum SelfAccessibility { CFGetTypeID(value) == AXUIElementGetTypeID() else { return nil } - return (value as! AXUIElement) + return value as! AXUIElement } #endif @@ -154,11 +156,16 @@ enum SelfAccessibility { let result = AXUIElementCopyAttributeValue(systemWide, kAXFocusedUIElementAttribute as CFString, &ref) - guard result == .success, - let value = ref, - CFGetTypeID(value) == AXUIElementGetTypeID() else { + 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 } diff --git a/gui/Sources/TetherKitApp/Views/SidebarShell.swift b/gui/Sources/TetherKitApp/Views/SidebarShell.swift index f60cd9c..0662ba7 100644 --- a/gui/Sources/TetherKitApp/Views/SidebarShell.swift +++ b/gui/Sources/TetherKitApp/Views/SidebarShell.swift @@ -221,10 +221,13 @@ struct SidebarShell: View { /// /// 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 7 - /// contract — the system key-view loop cannot reach these rows, - /// round 10 probed every row style), and bare Return is resolved - /// against the system-wide focus track with FKA on (round 11). + /// 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 From d5ef832465cc54c3e79fb4da18cdc2d2d3d6c162 Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:21:16 +0530 Subject: [PATCH 6/9] test(gui): verify reverse page navigation in keyboard probe --- gui/Sources/TetherKitApp/KeyboardProbe.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/gui/Sources/TetherKitApp/KeyboardProbe.swift b/gui/Sources/TetherKitApp/KeyboardProbe.swift index 02d15dd..dec15f3 100644 --- a/gui/Sources/TetherKitApp/KeyboardProbe.swift +++ b/gui/Sources/TetherKitApp/KeyboardProbe.swift @@ -35,10 +35,13 @@ import TetherKitUIModel /// contract (no stale-row actuation) /// - bare Return is inert → FKA-off /// contract -/// - bare Tab moves focus, not selection → the self-driven -/// ring is REMOVED (round 11): Tab navigates between sidebar -/// and content buttons without mutating page selection +/// - 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 from content focus steps the page → round 11 full-state arrows +/// - an Up then steps back → reverse round trip /// /// Exits 0 on PASS (or walk completion), 1 on FAIL, 3 on the 20 s /// watchdog, 4 if event synthesis failed. Armed once, from @@ -234,7 +237,14 @@ enum KeyboardProbe { if down2 == tab2 { failures.append("down arrow did not step page while button was focused") } - finish() + 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() + } } } } From 4b296932ae0223c8dd7f05d5c3e401728e1f322d Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:21:27 +0530 Subject: [PATCH 7/9] docs: clarify selection invariants and arrow key contracts --- AGENTS.md | 17 ++++++++++------- docs/GUI-ARCHITECTURE.md | 13 +++++++++---- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fd05c02..d1dea61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -707,7 +707,7 @@ TX 就从 4.8 回到了 87 Mbps。 Enter 在 `SidebarMainWindow.performKeyEquivalent` 拦(AppKit 键 等价相位先于 firstResponder 派发,且免增加/卸载监听的生命周期 管理),条件是系统级 AX 焦点解析出行标题且 field editor 未激活, - 否则原样放行;③ 聚焦行自身の `.onKeyPress(.return)` 保留为兜底(Round 11 定型; + 否则原样放行;③ 聚焦行自身的 `.onKeyPress(.return)` 保留为兜底(Round 11 定型; 容器仅处理方向键)。同时 移除了 `model.fullKeyboardAccess` 门(AppModel 的轮询与属性一并 回退):`NSApp.isFullKeyboardAccessEnabled` 读的是旧偏好 @@ -730,8 +730,8 @@ TX 就从 4.8 回到了 87 Mbps。 行上的 AX 焦点只是「上次浏览残留」(点行或 Tab 摸过后赖着 不走),裸 Space/Return 会静默重激活那一行(真机症状:哪里按 都跳到管理页),必须保持 inert。行的 AXPress 路径**不设门** - (VoiceOver 无关此开关)。② 键盘导航(`AppleKeyboardUIMode=2`) - 下方向键到不了 SwiftUI 容器:AppKit 键导航在 firstResponder + (VoiceOver 无关此开关)。② (探索历史 / superseded —— 现行方向键契约以 Round 11 ② 为准): + 键盘导航(`AppleKeyboardUIMode=2`)下方向键到不了 SwiftUI 容器:AppKit 键导航在 firstResponder 派发之前吃掉它们(真机症状:先点一下侧栏方向键才活)。修在 `SidebarMainWindow.performKeyEquivalent`(先于 firstResponder 相位)认领裸方向键 125/126:条件是 FKA-off + first responder @@ -744,8 +744,8 @@ TX 就从 4.8 回到了 87 Mbps。 `.onKeyPress` 不会二次步进。③ ⚠️ 修饰键判据陷阱:方向键 keyDown 一贯带 `.function` 位(NSModifierFlags 设备无关的硬件 键标记,无实体修饰键按下时也在),把 `deviceIndependentFlags - Mask` 整位掩码当和弦禁令会静默拒掉所有裸方向键(探针计数 - 证据:键进了 `performKeyEquivalent`、箭头分支却没跑);和弦 + Mask` 整位掩码当和弦禁令会静默拒掉所有裸方向键(`TETHERKIT_FKA_OVERRIDE` + 覆写 + PROBE 打印证据:键进了 `performKeyEquivalent`、箭头分支却没跑);和弦 禁令必须是显式集合 `[.capsLock, .shift, .control, .option, .command]`。④ 无头验证矩阵(注入 `NSApp.sendEvent`,绕不过 真实 HID tap,故 real-FKA-on 的 Space/方向键终判仍在真机): @@ -756,7 +756,7 @@ TX 就从 4.8 回到了 87 Mbps。 换、Return 在焦点行上正确认领切换、文字视图安全;合成方向键 在 forced-UA 下仍经容器直切属无头混成态(真实 FKA-on 下 HID 已吃掉 NSEvent,不会发生),不是缺陷。探针脚手架中 AxProbe.swift - 已在 r7 验证后拆除;DEBUG fkaOverride 与按键计数作为回归守卫保留。 + 已在 r7 验证后拆除;DEBUG fkaOverride 作为回归守卫保留。 Round 8(焦点应答、Tab 归属与选择器收官): ① 进程内挪 AX 焦点的唯一受支持通道是 SwiftUI 状态包装: `.focused`/`.accessibilityFocused` 逐行绑定、 @@ -790,7 +790,8 @@ TX 就从 4.8 回到了 87 Mbps。 从此作废,未来探针矩阵先改期望;FKA-off 的 inert 不再靠修饰链、 改靠激活动作分流:FKA-off 门内按 `NSApp.currentEvent` 类型区分, 键盘驱动的原生激活(现场 keyDown)丢弃、鼠标事件放行。⚠️ - `currentEvent` 只在这一个位置、只作否定信号 —— AXPress 轮会残留 + `currentEvent` 的读取有两处(此门与 `ButtonTurn.write` 的跳拍 + 判定),均只作否定/分流信号 —— AXPress 轮会残留 旧事件(本坑 round 6),别拿它做正向判据。 ② Return 真机失败补记:窗口 `performKeyEquivalent` 认领的 AX 自读在合成探针下能解析出行,用户真机 FKA 下解析不出 → Return @@ -813,6 +814,8 @@ TX 就从 4.8 回到了 87 Mbps。 ② 方向键活化:`handleArrowKey` 守卫改为仅在 `isFirstResponderEditableText` 为真时放行文本编辑(移动光标),按钮/卡片/滚动区获焦时方向键随时直步换页, 换页自愈焦点,用户绝不被卡在当前页。容器 `.onKeyPress` 保留上下方向键作为兜底。 + FKA-on 时方向键由系统焦点引擎接管(HID 层的「移动焦点」键),窗口认领 + 与容器兜底都收不到事件,方向键移动的是 key 焦点本身 —— 预期行为,不是缺陷。 ③ Return 认领与行级后背:窗口 `performKeyEquivalent` 在第一响应者非 field editor 时, 尝试自进程系统级 AX 焦点解析(需 TCC 信任);若未信任或未解析出行,事件穿透至聚焦行自身 `.onKeyPress(.return)` 兜底换页。 diff --git a/docs/GUI-ARCHITECTURE.md b/docs/GUI-ARCHITECTURE.md index 58e47c1..c78286c 100644 --- a/docs/GUI-ARCHITECTURE.md +++ b/docs/GUI-ARCHITECTURE.md @@ -373,8 +373,11 @@ 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:`); @@ -410,7 +413,8 @@ round 7)、自进程系统级 AX 焦点解析出行标题、field editor 未 驱动的换筛选/清空不再带激活事务的插值。 **焦点应答同步与自愈探索(round 8,自愈逻辑已在 round 11 superseded)**:行的键盘参与按辅助功能面板真值 -门控(`.focusable(SelfAccessibility.isFullKeyboardAccessOn)`); +门控(round 11 定型:每行声明 +`.focusable(SelfAccessibility.isFullKeyboardAccessOn || isSelected)`,容器不再挂 focusable); `.focused`/`.accessibilityFocused` 双引擎逐行绑定,键盘引擎落点 经 `onChange(of: focusedRow)` 同步为 AX 应答 —— 这是进程内挪 AX 焦点的唯一受支持通道,外部写 `kAXFocusedUIElement` 是 no-op(坑 @@ -434,8 +438,9 @@ TetherKitUIModel,commit 语义有单测)。 再少一处,AX 身份更稳;r8 探针矩阵的期望项「Tab 永不落行」从此作 废,未来矩阵先改期望);行 Button 动作统一走 `rowSelect`,FKA-off 时按 `NSApp.currentEvent` 的类型分流 —— 键盘驱动的原生激活(现场 -keyDown)丢弃、鼠标事件放行(`currentEvent` 只在这一个位置、只作 -否定信号用:AXPress 轮会残留旧事件,见坑 24 round 6);窗口方向键 +keyDown)丢弃、鼠标事件放行(`currentEvent` 的读取有两处 —— 此处与 +`ButtonTurn.write` 的跳拍判定 —— 都只作否定/分流信号用:AXPress 轮 +会残留旧事件,见坑 24 round 6);窗口方向键 认领在第一响应者非文本输入框时步进页面。Return 补行级后背:真机 FKA 下窗口认领依赖的 AX 自读解析不出 行(合成探针全过 —— 无头环境绕不开 HID 层,这一环只能真机验), 聚焦行自己挂 `.onKeyPress(.return)`(FKA-on 门,直调 `rowSelect`, From a716ce0cd18d015c7885b54f51b64a9dae0801d9 Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:49:46 +0530 Subject: [PATCH 8/9] test(gui): assert exact page successor and validate probe mode --- gui/Sources/TetherKitApp/KeyboardProbe.swift | 33 +++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/gui/Sources/TetherKitApp/KeyboardProbe.swift b/gui/Sources/TetherKitApp/KeyboardProbe.swift index dec15f3..20ab76c 100644 --- a/gui/Sources/TetherKitApp/KeyboardProbe.swift +++ b/gui/Sources/TetherKitApp/KeyboardProbe.swift @@ -3,8 +3,8 @@ import AppKit import TetherKitUIModel /// Headless keyboard probe (DEBUG only; opt-in via -/// `TETHERKIT_SB_PROBE=walk|matrix|fka_on|full`; any other value runs -/// the FKA-off contract suite). +/// `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, @@ -31,6 +31,8 @@ import TetherKitUIModel /// 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 @@ -40,8 +42,12 @@ import TetherKitUIModel /// 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 from content focus steps the page → round 11 full-state arrows -/// - an Up then steps back → reverse round trip +/// - 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 @@ -86,8 +92,11 @@ enum KeyboardProbe { runMatrixSuite() case "fka_on": runFkaOnSuite() - default: + case "full": runFullSuite() + default: + failures.append("unknown TETHERKIT_SB_PROBE mode \(mode)") + finish() } } @@ -237,6 +246,20 @@ enum KeyboardProbe { 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") From 478cc5dcdfda5b6fcbcc0898ad1186a9b82af28a Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:50:00 +0530 Subject: [PATCH 9/9] docs(gui): document AX focus sync bypass and normalize agent notes --- AGENTS.md | 2 +- gui/Sources/TetherKitApp/Views/SidebarShell.swift | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index d1dea61..c7cfc12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -756,7 +756,7 @@ TX 就从 4.8 回到了 87 Mbps。 换、Return 在焦点行上正确认领切换、文字视图安全;合成方向键 在 forced-UA 下仍经容器直切属无头混成态(真实 FKA-on 下 HID 已吃掉 NSEvent,不会发生),不是缺陷。探针脚手架中 AxProbe.swift - 已在 r7 验证后拆除;DEBUG fkaOverride 作为回归守卫保留。 + 已在 Round 7 验证后拆除;DEBUG fkaOverride 作为回归守卫保留。 Round 8(焦点应答、Tab 归属与选择器收官): ① 进程内挪 AX 焦点的唯一受支持通道是 SwiftUI 状态包装: `.focused`/`.accessibilityFocused` 逐行绑定、 diff --git a/gui/Sources/TetherKitApp/Views/SidebarShell.swift b/gui/Sources/TetherKitApp/Views/SidebarShell.swift index 0662ba7..7633ddc 100644 --- a/gui/Sources/TetherKitApp/Views/SidebarShell.swift +++ b/gui/Sources/TetherKitApp/Views/SidebarShell.swift @@ -70,6 +70,11 @@ struct SidebarShell: View { /// 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