Skip to content

feat(remap): button remap engine — HID++ 0x1B04 setControlReporting + CGEvent dispatch#8

Merged
Sanjays2402 merged 1 commit into
mainfrom
v0.6-button-remap
May 9, 2026
Merged

feat(remap): button remap engine — HID++ 0x1B04 setControlReporting + CGEvent dispatch#8
Sanjays2402 merged 1 commit into
mainfrom
v0.6-button-remap

Conversation

@Sanjays2402

Copy link
Copy Markdown
Owner

Summary

The Buttons pane was a read-only catalog from v0.4. With this PR, every reprogrammable button on every paired Logitech device can route its events to a host-side action of the user's choosing — Mission Control on a side button, ⌘C on Forward, app launch on the gesture button — without Logi Options+ running.

This is the core feature that takes Optune from "telemetry viewer" to "actual Options+ replacement".

How it works

The firmware can either send a button as native HID (default — gives you a system-defined back/forward/etc.) or as an HID++ notification with the raw CID encoded. We turn on the divert bit per-CID for any CID with a binding, listen on the existing input pipe for those notifications, decode the pressed CIDs, and translate to a CGEvent / NSWorkspace launch / shell.

OptuneCore

HIDPPTransport.swift — added an event-stream subscriber path:

public typealias EventHandler = @Sendable (HIDPPResponse) -> Void
@discardableResult public func addEventSubscriber(_:) -> UInt64
public func removeEventSubscriber(_ token: UInt64)

Previously frames with swID == 0 (hardware-initiated, the channel firmware uses for diverted events / battery notifications / host-switch events) were silently dropped. Now they fan out to subscribers on the transport's serial queue. Subscribers are cleared on close().

ReprogControlsV4.swift — added the missing reporting half of the feature:

  • Reporting struct (per-CID flags: diverted, persistentDivert, rawXYDiverted, remap target)
  • getReporting(cid:) via fn 0x2
  • setReporting(...) via fn 0x3 — uses the change-mask convention (hi nibble = which bits to apply, lo nibble = values), matches Solaar's settings_templates
  • decodeButtonEvent(_:) — pulls up to 4 currently-pressed CIDs (16-bit BE) out of a 0x1B04 fn 0x0 notification

OptuneApp

RemapEngine.swift (new, ~170 lines) — @MainActor engine that:

  • Owns a HIDPPTransport reference (passed in)
  • apply(bindings:featureIndex:) — reconciles firmware divert flags (divert ONLY the CIDs with a non-.none binding, un-divert the rest — important so we don't leave the user's mouse in a half-diverted state if they remove a binding)
  • Subscribes to events, decodes button frames, rising-edge debounce (only fires on the pressedCIDs.subtracting(lastPressed) set so a 200ms hold isn't 50 fires)
  • Dispatches the action:
    • .keystrokeCGEvent(keyboardEventSource:virtualKey:keyDown:) posted to .cgSessionEventTap with caller-supplied modifier flags
    • .systemSwipe → desugars to the right F-key keystroke (F3 / ⌃↓ / F11 / F4)
    • .openAppNSWorkspace.shared.openApplication(at:configuration:)
    • .runShellProcess via /bin/zsh -lc
  • teardown(featureIndex:) — unsubscribes + un-diverts every reprogrammable CID. Called on disconnect or when bindings drain to empty so we don't leak diversion state across app restart.

DeviceModel — added the public surface:

  • @Published private(set) var remapBindings: [UInt16: RemapAction]
  • remapAction(for cid: UInt16) (UI lookup helper)
  • setRemap(cid:action:) — updates in-memory map immediately for UI responsiveness, persists, then async-reconciles the engine
  • reconcileRemapEngine(for:) — idempotent: opens transport on demand, looks up 0x1B04 feature index, applies bindings; tears everything down (incl. closing transport) when no bindings remain
  • refresh() — on a newly-appearing device, hydrate persisted bindings and stand the engine back up

SettingsStore — new remapBindings: [RemapBinding]? field on DeviceSettings. Encoded inline alongside the rest of the per-device prefs.

Settings UI

ButtonsPane subtitle promoted from "Read-only catalog... Runtime remap arrives in v0.5." to a description of what the pane actually does. Every reprogrammable row gets a Menu picker on the right with three sections:

Section Options
System Mission Control · Application Windows · Show Desktop · Launchpad
Keystroke ⌘C · ⌘V · ⌘Z · ⌘⇧Z · ⌘Tab
App Safari · Terminal · Notes

The picker is preset-driven on purpose — full keystroke / bundleID / shell-string editors are an obvious next iteration, but the 80% case is covered and the engine handles all four RemapAction cases already, so a custom-string editor is purely UI.

The current binding's displayName shows in the menu label with an accent highlight when active, neutral when .none.

Permissions caveat

Synthesizing CGEvent posts to the session event tap requires Accessibility permission. The app already asks for HID Input Monitoring; on first use of a remap that synthesizes keystrokes, macOS will prompt for Accessibility too. App-launch + shell actions don't need it.

A v0.7 task is to add a clearer pre-flight prompt in the Welcome window so the user grants both at once.

Test notes

  • swift build clean
  • App bundles + launches
  • ButtonsPane renders the picker for each reprogrammable CID; .none shows "Disabled" with neutral chrome, picked actions show their display name with accent fill
  • Engine initialization isn't on the launch hot path — it's lazy on first setRemap call
  • Disconnect/reconnect cycle correctly tears down + rebuilds engine via reconcileRemapEngine
  • The transport's event-subscribe path doesn't break existing send/await correlation (added subscribers run on the same serial queue, after the swID==0 short-circuit, so request continuations are unaffected)

What's intentionally not here

  • Custom keystroke / bundleID / shell editors — UI-only follow-up, engine handles them
  • Onboard-flash button maps (DeviceMemory 0x1806 writes) — different feature, deferred
  • Per-app remap profilesAppProfileManager exists but doesn't switch remap bindings yet; trivial follow-up
  • Visual feedback when a button fires — would need a transient menu-bar toast, deferred

Closes the v0.6 button-remap item.

…nt dispatch (#7)

The Buttons pane was a read-only catalog. Now every reprogrammable
button on every paired Logitech device can route its events to a
host-side action of the user's choosing. Mission Control on a side
button, ⌘C on Forward, app launch on the gesture button — without
Logi Options+ running.

How it works:

- HIDPPTransport: subscribe(_:) — adds an event-stream callback for
  swID==0 hardware-initiated frames (formerly silently dropped).
  Returns a token; multiple subscribers OK; cleared on close().
- ReprogControlsV4Feature: getReporting(cid) / setReporting(cid, ...)
  via fn 0x2 / 0x3, plus decodeButtonEvent() that pulls the up-to-4
  big-endian CIDs out of an incoming notification.
- OptuneApp/RemapEngine: @mainactor engine. apply(bindings:) reconciles
  firmware divert flags (only divert CIDs that have a real binding;
  un-divert the rest), subscribes to events, and on rising-edge presses
  fires the bound RemapAction via CGEvent post / NSWorkspace / Process.
- DeviceModel: setRemap(cid:action:) for UI; reconcileRemapEngine() for
  device hot-plug; persistence via DeviceSettings.remapBindings; engine
  + transport are torn down when no bindings remain so we don't leak a
  divert state across app restart-without-bindings.
- SettingsStore: new `remapBindings: [RemapBinding]?` field.
- Settings UI: ButtonsPane subtitle promoted from "v0.5" placeholder.
  Each reprogrammable row gets a Menu picker with Disabled / System
  (Mission Control / App Windows / Show Desktop / Launchpad) / Keystroke
  presets (⌘C / ⌘V / ⌘Z / ⌘⇧Z / ⌘Tab) / App (Safari / Terminal / Notes).

The action menu is preset-driven on purpose — keystroke + bundleID +
shell-string editors are easy to add later but the 80%-case is covered.
@Sanjays2402 Sanjays2402 merged commit e9dfc83 into main May 9, 2026
1 check passed
@Sanjays2402 Sanjays2402 deleted the v0.6-button-remap branch May 9, 2026 23:53
Sanjays2402 added a commit that referenced this pull request May 10, 2026
…res (#10)

The button-remap engine in #8 dispatched actions with CGEvent.post(), which
silently drops every event when the process isn't trusted under System
Settings → Privacy & Security → Accessibility. There was no AX check and
no UI signal — the toggle in the remap menu would persist, the firmware
would correctly divert the button, but pressing it did nothing because
macOS dropped the synthesized keystroke into the void.

Worse: WelcomeWindow.swift literally said "Optune does not require
accessibility" — a leftover from before the remap engine landed.

Worst: macOS keys Accessibility grants on the binary's signature/path.
Every brew upgrade or rebuild invalidates the prior grant while the toggle
in the System Settings list visibly stays on, leaving the user thinking
they've granted permission when they haven't.

Changes:
- Add AccessibilityChecker singleton (@mainactor ObservableObject) that
  polls AXIsProcessTrusted() on a 2s timer, stops polling once trusted,
  and exposes requestPrompt() / openSettingsPane() helpers.
- Gate every CGEvent-dispatching action in RemapEngine on AXIsProcessTrusted();
  if denied, refresh the checker and pop the system prompt instead of
  silently no-op'ing.
- Replace the lying Welcome 'permissions' page: now lists Input Monitoring
  AND Accessibility as separate rows, with live trust status, a Grant
  button that triggers the prompt, and a tip about re-adding after updates.
- Add an Accessibility nag banner at the top of ButtonsPane when remap
  bindings exist but the trust grant is missing, so the user sees the
  exact recovery path right where they're about to be confused.
- Bump CFBundleVersion to 0.6.0 in bundle-app.sh.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant