feat(remap): button remap engine — HID++ 0x1B04 setControlReporting + CGEvent dispatch#8
Merged
Merged
Conversation
…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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
OptuneCoreHIDPPTransport.swift— added an event-stream subscriber path: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 onclose().ReprogControlsV4.swift— added the missing reporting half of the feature:Reportingstruct (per-CID flags: diverted, persistentDivert, rawXYDiverted, remap target)getReporting(cid:)via fn 0x2setReporting(...)via fn 0x3 — uses the change-mask convention (hi nibble = which bits to apply, lo nibble = values), matches Solaar'ssettings_templatesdecodeButtonEvent(_:)— pulls up to 4 currently-pressed CIDs (16-bit BE) out of a 0x1B04 fn 0x0 notificationOptuneAppRemapEngine.swift(new, ~170 lines) —@MainActorengine that:HIDPPTransportreference (passed in)apply(bindings:featureIndex:)— reconciles firmware divert flags (divert ONLY the CIDs with a non-.nonebinding, un-divert the rest — important so we don't leave the user's mouse in a half-diverted state if they remove a binding)pressedCIDs.subtracting(lastPressed)set so a 200ms hold isn't 50 fires).keystroke→CGEvent(keyboardEventSource:virtualKey:keyDown:)posted to.cgSessionEventTapwith caller-supplied modifier flags.systemSwipe→ desugars to the right F-key keystroke (F3 / ⌃↓ / F11 / F4).openApp→NSWorkspace.shared.openApplication(at:configuration:).runShell→Processvia/bin/zsh -lcteardown(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 enginereconcileRemapEngine(for:)— idempotent: opens transport on demand, looks up 0x1B04 feature index, applies bindings; tears everything down (incl. closing transport) when no bindings remainrefresh()— on a newly-appearing device, hydrate persisted bindings and stand the engine back upSettingsStore— newremapBindings: [RemapBinding]?field onDeviceSettings. Encoded inline alongside the rest of the per-device prefs.Settings UI
ButtonsPanesubtitle promoted from"Read-only catalog... Runtime remap arrives in v0.5."to a description of what the pane actually does. Every reprogrammable row gets aMenupicker on the right with three sections: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
RemapActioncases already, so a custom-string editor is purely UI.The current binding's
displayNameshows in the menu label with an accent highlight when active, neutral when.none.Permissions caveat
Synthesizing
CGEventposts 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 buildclean.noneshows "Disabled" with neutral chrome, picked actions show their display name with accent fillsetRemapcallreconcileRemapEngineWhat's intentionally not here
AppProfileManagerexists but doesn't switch remap bindings yet; trivial follow-upCloses the v0.6 button-remap item.