Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
20 changes: 15 additions & 5 deletions Sources/WLKit/BridgeController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
55 changes: 55 additions & 0 deletions Tests/WLKitTests/BridgeContentionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions design/pad-contention.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 7 additions & 2 deletions docs/hacking.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading