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. A reply with a response id we never issued is how `contendingClient` is detected (`noteResponse(id:)` and `noteIssued(id:)`, also the test seams). Detection is passive, so the flag outlives the other app until `start()`, `stop()` or the panel's Recheck (`recheckContention()`: clear, then repaint if running) resets it. 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 active registry check is planned in `design/pad-contention.md`. 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. `contendingClient` has two signals, combined only in `updateContention()`: a reply with a response id we never issued sets `trafficSeen` (`noteResponse(id:)` and `noteIssued(id:)`, also the test seams), and an IORegistry scan (`WLDevice.otherClients()`, behind the `scanClients` seam) lists the other processes holding the pad. An `.authoritative` scan (USB vendor interface) with others raises the flag by itself, unless Recheck acknowledged them (`acknowledgedPIDs`: the pids a Recheck's scan listed, so an app that opens every HID device and never sends cannot pin the warning; pruned by every scan that no longer lists them, so a relaunch raises again; kept by `.unavailable`; reset by `start()`/`stop()`; their replies still raise through `trafficSeen`); an `.advisory` one (Bluetooth) only names them; an empty list from either clears `trafficSeen`; `.unavailable` (emulator, failed walk) leaves the replies to decide. Scans run on panel open (`scanContention()`), on Recheck (`recheckContention()`: clear traffic, scan, acknowledge, repaint if running), after `openDevice()` connects, and after a foreign reply (at most once per `replyScanInterval`); never while off. `contenders` names them in the warning. 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
27 changes: 21 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,12 +289,27 @@ 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.

Detection is passive, so once raised the warning stays, even after the other
app has quit. Quit it, then press **Recheck** next to the warning: that clears
it and repaints the pad with the manager's colours. If the other app is still
there, the warning comes back as soon as it next sends to the pad. Switching
the manager off clears the warning too. An active check that clears it by
itself is planned in [design/pad-contention.md](design/pad-contention.md).
The manager also asks macOS which other processes hold the pad open, each time
the panel opens. Over USB that answer is exact: the warning names the other app
("Also driving this pad: Input — colours may fight."), and it clears by itself
the next time you open the panel after that app has quit. **Recheck** next to
the warning checks again on the spot and repaints the pad with the manager's
colours. Switching the manager off clears the warning too.

Some apps (input remappers, macro or streaming tools) open every HID device
without ever touching the lighting, and would keep the warning up for as long
as they run. **Recheck** accepts the apps holding the pad at that moment: they
no longer raise the warning on their own, though they are still named if it
comes up for another reason. It comes back when one of them sends to the pad,
when a different app opens it, or when an accepted app quits and is started
again. Switching the manager off and on forgets what was accepted.

Over Bluetooth the pad is a single device that also carries the keyboard, so
apps that only listen for keys (chat apps, for one) may hold it open too. There the
check can name the apps but not raise the warning by itself: it still takes a
reply we never asked for, and it stays until the other app has quit and you
press **Recheck**, or the check finds nobody else at all. See
[design/pad-contention.md](design/pad-contention.md) for the details.

## Development

Expand Down
140 changes: 126 additions & 14 deletions Sources/WLKit/BridgeController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,14 @@ public final class BridgeController: ObservableObject {
@Published public private(set) var keyEffects: [Int: OAI.Effect] = [:]
@Published public private(set) var aggregateState: String?
@Published public private(set) var lastError: String?
/// Another process is talking to the same pad. A shared HID open means we
/// receive its replies too, so a response id we never issued is a reliable
/// tell.
/// Another process is driving the same pad. Two signals feed it: a reply
/// id we never issued (a shared HID open means we receive other clients'
/// replies too), and a scan of the IORegistry for other processes holding
/// the pad open. `updateContention()` has the rule that combines them.
@Published public private(set) var contendingClient = false
/// The other processes the last registry scan found, to name them in the
/// warning. Empty when the scan was unavailable.
@Published public private(set) var contenders: [HIDClient] = []
/// Whether the GitButler stack is on screen. Only the key light cares —
/// the panel itself lives in the app layer.
@Published public private(set) var stackPanelOpen = false
Expand Down Expand Up @@ -90,7 +94,8 @@ public final class BridgeController: ObservableObject {

// MARK: - Internals

private var device = WLDevice()
/// Internal-read so tests can unplug the virtual pad.
private(set) var device = WLDevice()
/// Non-nil while the bridge is driving a virtual pad instead of hardware.
@Published public private(set) var emulator: PadEmulator?
private var lifecycle: HerdrEventStream?
Expand All @@ -101,6 +106,29 @@ public final class BridgeController: ObservableObject {
private var reopenTask: Task<Void, Never>?
private var lastFingerprint: String?
private var issuedIDs = Set<Int>()
/// A reply id we never issued has been seen since the last clear.
private var trafficSeen = false
/// The last registry scan's verdict; `.unavailable` until one has run
/// on an open device.
private var lastScan: HIDClientScan = .unavailable
/// Other clients the user let stay with Recheck. They still hold the
/// pad, but no longer raise the warning on their own: an app that opens
/// every HID device and never sends would otherwise keep it up for good.
/// Their replies still do, through `trafficSeen`. A pid drops out once a
/// scan no longer lists it, so a relaunched app raises the warning again.
private var acknowledgedPIDs = Set<pid_t>()
/// When the last reply-triggered scan ran, on the `uptime` clock.
private var lastReplyScan: TimeInterval?
/// Who else holds the pad open. A seam for tests; reads the device at
/// call time, since `useEmulator` replaces it.
lazy var scanClients: @MainActor () -> HIDClientScan = { [weak self] in
self?.device.otherClients() ?? .unavailable
}
/// A monotonic clock for the reply-triggered scan limit. A seam for tests.
var uptime: @MainActor () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }
/// The least time between two scans triggered by foreign replies, so a
/// chatty client cannot turn every reply into a registry walk.
static let replyScanInterval: TimeInterval = 1
private var warnedPermission = false
/// Owned while the target is remote and the bridge runs.
private var tunnel: SSHTunnel?
Expand Down Expand Up @@ -156,6 +184,9 @@ public final class BridgeController: ObservableObject {
guard let self else { return }
self.deviceConnected = false
self.lastFingerprint = nil
// Who held a pad that is gone says nothing; the reopen rescans.
self.lastScan = .unavailable
self.updateContention()
if self.isRunning { self.scheduleReopen() }
}
device.onTX = { [weak self] _, _, id in
Expand All @@ -181,22 +212,100 @@ public final class BridgeController: ObservableObject {
/// reply id the bridge never issued, as another client's would.
func noteResponse(id: Int) {
// A reply to an id we never sent came from another client.
if issuedIDs.remove(id) == nil { contendingClient = true }
}

/// The panel's Recheck: clears the warning, and repaints so our colours
/// replace whatever the other app left on the pad. Detection is passive,
/// so this proves nothing by itself — the warning comes back as soon as
/// the other app, if it is still there, sends to the pad again.
guard issuedIDs.remove(id) == nil else { return }
trafficSeen = true
// Scan to put a name on it, at most once a second. A reply within
// the window still raises the warning; it just keeps the last names.
let now = uptime()
if lastReplyScan.map({ now - $0 >= Self.replyScanInterval }) ?? true {
lastReplyScan = now
applyScan()
} else {
updateContention()
}
}

/// 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.
public func scanContention() {
applyScan()
}

/// The panel's Recheck: forgets the replies seen so far, rescans,
/// acknowledges the other clients the scan still lists, and repaints so
/// our colours replace whatever the other app left on the pad. This
/// proves nothing by itself — the warning comes back as soon as the
/// other app, if it is still there, sends to the pad again, or a client
/// that was not acknowledged opens it.
public func recheckContention() async {
// `issuedIDs` is left alone on purpose: a reply to one of our own
// calls still in flight would otherwise look foreign and raise the
// warning again at once.
contendingClient = false
trafficSeen = false
applyScan()
if case .authoritative(let others) = lastScan {
acknowledgedPIDs = Set(others.map(\.pid))
updateContention()
}
guard isRunning else { return }
await forceRepaint()
}

/// Scans the registry, if there is an open pad to scan. An off bridge
/// drives nothing, so its scan is `.unavailable` without asking.
private func applyScan() {
lastScan = isRunning && deviceConnected ? scanClients() : .unavailable
switch lastScan {
case .authoritative(let others), .advisory(let others):
// Nobody else holds the pad now, so whoever sent those replies
// has gone. Only a fresh scan clears: replies seen after it
// raise the warning again until the next one.
if others.isEmpty { trafficSeen = false }
// An acknowledged app that has quit is forgotten, so the same app
// relaunched (a new pid) raises the warning again.
acknowledgedPIDs.formIntersection(others.map(\.pid))
case .unavailable:
break
}
updateContention()
}

/// The one place `contendingClient` is decided, from the replies seen
/// and the last scan:
///
/// - unavailable: the replies alone (passive detection);
/// - authoritative (a dedicated vendor interface, USB): anyone else
/// there can drive the lighting, so a client Recheck did not
/// acknowledge raises it;
/// - advisory (the pad's only interface, Bluetooth): others may only be
/// listening for keys, so the list names but cannot raise.
///
/// An empty list has already cleared the replies, in `applyScan()`.
private func updateContention() {
switch lastScan {
case .unavailable:
contenders = []
contendingClient = trafficSeen
case .authoritative(let others):
contenders = others
contendingClient = trafficSeen || others.contains { !acknowledgedPIDs.contains($0.pid) }
case .advisory(let others):
contenders = others
contendingClient = trafficSeen
}
}

/// Clears both signals and the acknowledgement; for `start()` and
/// `stop()`.
private func resetContention() {
trafficSeen = false
lastScan = .unavailable
acknowledgedPIDs = []
lastReplyScan = nil
updateContention()
}

// MARK: - Lifecycle

public func toggle() async {
Expand All @@ -207,7 +316,7 @@ public final class BridgeController: ObservableObject {
guard !isRunning else { return }
isRunning = true
lastError = nil
contendingClient = false
resetContention()
keyBindings = KeyBindings.load()

openingDevice = true
Expand All @@ -228,7 +337,7 @@ public final class BridgeController: ObservableObject {
// Cleared after the teardown, whose lights-off calls wait on replies
// that another client's may land among; a start() that came in
// meanwhile owns the flag.
if !isRunning { contendingClient = false }
if !isRunning { resetContention() }
}

/// Points the pad at another Herdr server. Takes effect at once while
Expand Down Expand Up @@ -469,6 +578,9 @@ public final class BridgeController: ObservableObject {
warnedPermission = false
deviceName = device.info?.product ?? "Work Louder device"
lastError = nil
// A client that was already holding the pad is seen now, not
// only once it next sends something.
applyScan()
} catch {
deviceConnected = false
let message = error.localizedDescription
Expand Down
23 changes: 16 additions & 7 deletions Sources/WLMicroManager/MenuPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,14 @@ struct MenuPanelView: View {
if bridge.contendingClient {
Divider()
HStack(alignment: .firstTextBaseline, spacing: 8) {
Label(
"Another app is also driving this pad — colours may fight.",
systemImage: "exclamationmark.triangle"
)
Label(contentionText, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
Button("Recheck") { Task { await bridge.recheckContention() } }
.controlSize(.small)
.help("Clear the warning. It comes back if the other app sends to the pad again.")
.help("Check again for other apps, accept the ones still holding the pad, and repaint it. The warning comes back if one of them sends to the pad, or another app opens it.")
}
.padding(.horizontal, 14).padding(.vertical, 8)
}
Expand All @@ -63,15 +60,27 @@ struct MenuPanelView: View {
// relaunch. The window may be kept alive between openings, in which
// case only becoming key marks a new one — this panel's window, not
// the emulator or any other window of the app, which would re-read
// the config (and maybe restart a tunnel) for no reason.
.onAppear { reloadRemotes() }
// the config (and maybe restart a tunnel) for no reason. Who else
// holds the pad is re-read with it, so the warning clears by itself
// once the other app has quit.
.onAppear { reloadRemotes(); bridge.scanContention() }
.background(WindowReader { panel.window = $0 })
.onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { note in
guard let window = note.object as? NSWindow, window === panel.window else { return }
reloadRemotes()
bridge.scanContention()
}
}

/// Names the other apps when the registry scan knew them.
private var contentionText: String {
guard !bridge.contenders.isEmpty else {
return "Another app is also driving this pad — colours may fight."
}
let names = bridge.contenders.map(\.name).joined(separator: ", ")
return "Also driving this pad: \(names) — colours may fight."
}

// MARK: - Header

private var header: some View {
Expand Down
Loading
Loading