diff --git a/CLAUDE.md b/CLAUDE.md index 6c0a718..881d960 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ Three SwiftPM targets (`Package.swift`): ## Things that are easy to get wrong - **Firmware lies**: it answers `{"ok":1}` to any payload, malformed or not. The LEDs are the only ground truth. `PadEmulator` reproduces this on purpose (and the stock keymap, and silent unbound keys) — keep it faithful when changing it. -- **Only one HID client at a time**: Work Louder's Input app and the Codex desktop app fight over the same lighting. Only a reply with a response id we never issued raises `contendingClient` (`noteResponse(id:result:error:)` and `noteIssued(id:)`, also the test seams), and not a null one: Input sends `host.focused_app` on every app switch, which the firmware answers with a bare null (lighting calls answer `{"ok":1}`); the filter runs after the own-id check, so our own ids are still consumed. **Holding the pad never raises it**: Input keeps the vendor interface open in the background without sending, and a presence rule warned on every launch. An IORegistry scan (`WLDevice.otherClients()`, behind the `scanClients` seam; `.authoritative` and `.advisory` are treated alike) says who may have sent and when they have gone: `traffic` is `.from(pids)` — laid at the newcomers, i.e. others not in `quietPIDs` (seen holding the pad while no reply was outstanding), else at every other — and clears once a scan lists none of them; `.unattributed` (no scan could say) clears only on an empty list. Newcomers blamed go into `suspectedPIDs` and are never counted quiet while present, so a Recheck with the Inspector still open does not spread its next reply to Input. `reconcile()` applies a scan; `.unavailable` (emulator, failed walk) changes nothing; `start()`/`stop()` forget it all. Scans run on panel open (`scanContention()`), on Recheck (`recheckContention()`: clear traffic, scan, repaint if running), after `openDevice()` connects, and after a foreign reply (at most once per `replyScanInterval`); never while off. `contenders` names the suspects still present, else every other. A recheck must never clear `issuedIDs`: a reply to our own call still in flight would look foreign and re-raise the warning at once. The Inspector is a separate process and counts as another client. +- **Only one HID client at a time**: Work Louder's Input app and the Codex desktop app fight over the same lighting. Only a reply with a response id we never issued raises `contendingClient` (`noteResponse(id:result:error:)` and `noteIssued(id:)`, also the test seams), and only when it is an error or a dictionary with an `ok` key (`isAboutTheLighting`: lighting calls and `fs.write` answer `{"ok":1}`). Input sends `host.focused_app` on every app switch (answered with a bare null) and reconnects on every screen unlock, asking `device.status`/`sys.version` (answered with objects); neither touches the lighting. The filter runs after the own-id check, so our own ids are still consumed. **Holding the pad never raises it**: Input keeps the vendor interface open in the background without sending, and a presence rule warned on every launch. An IORegistry scan (`WLDevice.otherClients()`, behind the `scanClients` seam; `.authoritative` and `.advisory` are treated alike) says who may have sent and when they have gone: `traffic` is `.from(pids)` — laid at the newcomers, i.e. others not in `quietPIDs` (seen holding the pad while no reply was outstanding), else at every other — and clears once a scan lists none of them; `.unattributed` (no scan could say) clears only on an empty list. Newcomers blamed go into `suspectedPIDs` and are never counted quiet while present, so a Recheck with the Inspector still open does not spread its next reply to Input. `reconcile()` applies a scan; `.unavailable` (emulator, failed walk) changes nothing; `start()`/`stop()` forget it all. Scans run on panel open (`scanContention()`), on Recheck (`recheckContention()`: clear traffic, scan, repaint if running), after `openDevice()` connects, and after a foreign reply (at most once per `replyScanInterval`); never while off. `contenders` names the suspects still present, else every other. A recheck must never clear `issuedIDs`: a reply to our own call still in flight would look foreign and re-raise the warning at once. The Inspector is a separate process and counts as another client. - **Signing matters**: Input Monitoring is granted per code signature, so ad-hoc builds need re-granting every rebuild. `bundle.sh` prefers a real Apple Development / Developer ID identity (`WL_SIGN_IDENTITY` overrides). `swift run` works only because it inherits the terminal's grant. - **A remote Herdr is an owned ssh**: `SSHTunnel` runs its own `/usr/bin/ssh` (`ControlMaster=no`, so the process lifetime *is* the tunnel's) with a remote `cat >/dev/null` on a stdin pipe we hold, so even a crash ends it by EOF. `HERDR_SOCKET_PATH` beats any tunnel, so the panel disables the target picker when it is set — and Herdr itself sets it for processes it spawns, so a `swift run` from inside a Herdr pane gets the disabled picker (and the `LiveBridgeTargetTests` skip). - **The socket path is global, so a switch must not leak.** `setSocketPath` bumps a generation; a reply that lands after a switch becomes `HerdrError.targetChanged`, and the bridge guards every async callback (tunnel state, lifecycle restart, status-stream `onClosed`, poll, in-flight `agent.list`) with its own generation counter plus a `herdrActive` flag. Event streams resolve the path in `start()`, not `init`. Pane ids are only unique per server — never carry agent state across targets. diff --git a/README.md b/README.md index ae47b70..ec5d056 100644 --- a/README.md +++ b/README.md @@ -286,8 +286,8 @@ downloading. Work Louder's Input app and the Codex desktop app drive this same pad. Running two at once means they overwrite each other. The panel detects this — the device is opened shared, so we receive other clients' replies, and a response id we -never issued is a reliable tell. The **Inspector** counts too: it is a separate -process with its own connection to the pad. +never issued is a reliable tell. The **Inspector** counts too, once it changes +the lighting: it is a separate process with its own connection to the pad. The manager also asks macOS which other processes hold the pad open, each time the panel opens and whenever such a reply comes in. The warning names the app @@ -299,8 +299,10 @@ the warning too. Holding the pad open is not driving it, so that alone never raises the warning. Work Louder's Input app keeps the pad open while it runs in the -background, and tells the pad which app is in front on every app switch; the -manager ignores those messages, since they leave the lighting alone. Some apps (input remappers, macro or streaming tools) open +background, tells the pad which app is in front on every app switch, and +reconnects and asks for the pad's status every time you unlock the Mac; the +manager ignores those messages, since they leave the lighting alone. Only +another app's lighting changes and keymap writes count. Some apps (input remappers, macro or streaming tools) open every HID device without ever touching the lighting. The manager remembers the apps that were holding the pad quietly, and blames a reply on an app that arrived since. If one of the quiet apps starts sending, the warning names all diff --git a/Sources/WLKit/BridgeController.swift b/Sources/WLKit/BridgeController.swift index a4272ab..da9c27c 100644 --- a/Sources/WLKit/BridgeController.swift +++ b/Sources/WLKit/BridgeController.swift @@ -231,11 +231,7 @@ public final class BridgeController: ObservableObject { func noteResponse(id: Int, result: Any?, error: String?) { // A reply to an id we never sent came from another client. guard issuedIDs.remove(id) == nil else { return } - // Not every call is about the lighting. Input announces the frontmost - // app (`host.focused_app`) on every app switch, and the firmware - // answers that with a bare null; lighting calls are answered - // {"ok":1}. A null reply cannot be a fight over the colours. - guard error != nil || !(result == nil || result is NSNull) else { return } + guard Self.isAboutTheLighting(result: result, error: error) else { return } // Scan to see who may have sent it, at most once a second. A reply // within the window still raises the warning; it is laid at the // clients of the last scan. @@ -252,6 +248,20 @@ public final class BridgeController: ObservableObject { reconcile() } + /// Whether another client's reply may be a fight over the colours. Only + /// the reply's shape is seen, never the call behind it. The lighting + /// calls (and keymap writes) are answered `{"ok":1}`; everything else + /// Input sends unprompted is not about the lighting: `host.focused_app` + /// on every app switch (a bare null), and `device.status` — or + /// `sys.version` if that fails — each time it reconnects, which it does + /// on every screen unlock (an object without `ok`). An error still + /// counts: nothing says what the failed call was. + static func isAboutTheLighting(result: Any?, error: String?) -> Bool { + if error != nil { return true } + guard let dict = result as? [String: Any] else { return false } + return dict["ok"] != nil + } + /// Re-reads who else holds the pad open, without repainting. The panel /// calls this every time it opens, so the warning clears by itself once /// the other app has quit. diff --git a/Tests/WLKitTests/BridgeContentionTests.swift b/Tests/WLKitTests/BridgeContentionTests.swift index cc1c503..de0aa6a 100644 --- a/Tests/WLKitTests/BridgeContentionTests.swift +++ b/Tests/WLKitTests/BridgeContentionTests.swift @@ -299,6 +299,61 @@ final class BridgeContentionTests: XCTestCase { XCTAssertTrue(bridge.contendingClient) } + /// The third report: after the Mac wakes and the screen is unlocked, + /// "Also driving this pad: input" until Recheck. Input reconnects to its + /// devices on every unlock — its client leaves the registry and comes + /// back — and asks for `device.status` (or `sys.version`) a second + /// later. Those replies are objects, not the lighting's {"ok":1}, so + /// they neither raise the warning nor scan, and Input stays quiet. + func testInputReconnectingAfterAnUnlockDoesNotRaise() async { + await startScanned(.authoritative([input])) + let pad = PadEmulator() + + scanResult = .authoritative([]) + bridge.scanContention() // the panel opens during the gap + scanResult = .authoritative([input]) + bridge.scanContention() + + let before = scans + for method in ["device.status", "sys.version"] { + let (result, error) = pad.handle(method, params: nil) + bridge.noteResponse(id: foreignID, result: result, error: error) + XCTAssertFalse(bridge.contendingClient, method) + } + XCTAssertEqual(scans, before, "a reply to a query does not scan") + + reply(while: [input, inspector]) + XCTAssertEqual(bridge.contenders, [inspector], "Input is still taken for quiet") + } + + /// Which of the emulator's answers count as a fight over the colours: + /// the lighting calls and the keymap write (all {"ok":1}) and errors do; + /// the queries and the focus announcement do not. + func testOnlyLightingAndWriteRepliesCount() { + let pad = PadEmulator() + let counted: [(String, Any?)] = [ + (OAI.methodThreads, OAI.threadsParams([])), + (OAI.methodRGBConfig, nil), + ("fs.write", ["file": "keymap.json", "data": "{}"]), + ("no.such.method", nil), + ] + let ignored: [(String, Any?)] = [ + ("sys.version", nil), + ("device.status", nil), + ("fs.list", nil), + ("fs.read", ["file": "keymap.json"]), + ("host.focused_app", ["name": "Finder"]), + ] + for (method, params) in counted { + let (result, error) = pad.handle(method, params: params) + XCTAssertTrue(BridgeController.isAboutTheLighting(result: result, error: error), method) + } + for (method, params) in ignored { + let (result, error) = pad.handle(method, params: params) + XCTAssertFalse(BridgeController.isAboutTheLighting(result: result, error: error), method) + } + } + /// A quiet holder that starts sending is all there is to blame, and the /// warning stays until it has gone. func testAQuietHolderThatSendsIsBlamed() async { diff --git a/design/pad-contention.md b/design/pad-contention.md index 2d482e0..438b962 100644 --- a/design/pad-contention.md +++ b/design/pad-contention.md @@ -549,6 +549,35 @@ Notes: (device-reply-result). - Not checked on hardware after the fix: whether the warning stays down through an Inspector round trip with Input running (user check). +- **Input's reconnect on unlock (third report).** After closing and reopening + the MacBook's lid, the panel read "Also driving this pad: input — colours + may fight." until Recheck. Cause, read from Input's bundle + (`/Applications/input.app/Contents/Resources/app.asar`): its main process + listens for Electron's `powerMonitor` `unlock-screen` and then calls + `disconnectAllDevices()` and `resetFoundDevices()`; device search + reconnects the pad, and `onDeviceConnected` waits 1 s and calls + `device.status` (falling back to `sys.version`). Both replies are objects, + so the null filter above let them through. Input was quiet, so the + fallback laid the reply at it, and the warning stayed for as long as Input + runs. (If a panel scan fell into the reconnect gap, Input came back as a + newcomer and was blamed all the same.) Fix: the filter is now an + allow-list by shape, `BridgeController.isAboutTheLighting(result:error:)`: + a foreign reply counts only when it carries an error or its result is a + dictionary with an `ok` key — what the lighting calls and `fs.write` + answer. Queries (`sys.version`, `device.status`, `fs.list`, `fs.read`) and + `host.focused_app` no longer count. Errors still count, as before. + Behaviour change: the Inspector only asks `sys.version` and + `device.status` when it connects, so opening it no longer raises the + warning; using its lighting controls (or the console's `v.oai.*`) does. + The earlier user checks that say "Inspector open → named warning" now + need a colour sent from the Inspector. +- Mutation checks for it, each restored afterwards: the null-only filter + back (unlock-reconnect, lighting-and-write-count); errors ignored + (error-reply, lighting-and-write-count). +- User checks, done by the user on hardware (not in-session): with Input + running and the bridge on, closing and opening the lid and unlocking + leaves no warning; a colour sent from the Inspector still raises the + named warning. ### Done diff --git a/docs/hacking.md b/docs/hacking.md index d62942f..e22b985 100644 --- a/docs/hacking.md +++ b/docs/hacking.md @@ -699,8 +699,13 @@ to say who may have sent them and when they have gone. Not every foreign reply is about the lighting, either. Input sends `host.focused_app` on every frontmost-app change, window or not, and the firmware answers it with a bare `"result": null` (checked on 2026-10-10; the -lighting calls answer `{"ok":1}`, `sys.version` an object). So a foreign reply -with a null result is ignored. Request ids are no help in telling senders +lighting calls answer `{"ok":1}`, `sys.version` an object). Input also +drops and reopens every device on each screen unlock (Electron's +`unlock-screen`), and a second after reconnecting asks for `device.status` +(`sys.version` if that fails), both answered with an object. Only the reply +is seen, never the call, so the bridge goes by its shape: a foreign reply +counts only when it is an error or a dictionary with an `ok` key (the +lighting calls and `fs.write`). Request ids are no help in telling senders apart: Input's look random, while `WLDevice` counts 1…998. **Variant gating is real.** These vendor methods are registered per hardware