From ec20ae7442e164afceedd03040c18939351838c9 Mon Sep 17 00:00:00 2001 From: Benjamin <1159333+benjaminburzan@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:48:04 +0200 Subject: [PATCH] fix(hid): capture and dispatch Back/Forward buttons on MX Vertical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MX Vertical (and similar Logitech mice) report Back and Forward buttons as HID++ 0x1b04 ReprogControls CIDs rather than standard OS mouse button 4/5 events. macOS never translates these into OtherMouseDown CGEvents, so OpenLogi's CGEventTap hook never saw them. Changes: - reprog_controls.rs: add BACK_CIDS (0x0053, 0xBD, 0xCE, 0xDB) and FORWARD_CIDS (0x0056, 0xCF) covering the MX Vertical's classic CIDs and the MultiPlatform Back/Forward CIDs used by other models - gesture.rs: divert Back/Forward CIDs in arm_controls; emit ButtonPressed(Back/Forward) on rising edge with 350ms debounce (the MX Vertical sends multiple DivertedButtons frames per physical click); capture frontmost app PID on the listener thread at press time so AX dispatch uses the correct app regardless of async dispatch delay; CapturedInput::ButtonPressed now carries Option frontmost_pid - inject.rs: add ax_browser_navigate() — uses AXPress on the toolbar's 'Go back'/'Go forward' AXButton for Safari (WKWebView ignores synthetic CGEvents); skips AXSplitGroup/AXTabGroup/AXRadioButton to avoid exhausting search depth on Safari's tab bar; falls back to Cmd+[/] for Chrome and other browsers - watchers/gesture.rs: use captured PID for AX navigation; fall back to dispatch_action (Cmd+[/]) when AX finds no matching button Config: add Back='BrowserBack' and Forward='BrowserForward' under the correct per-physical-device key in config.toml. Tested on MX Vertical (046d:b020) over Bluetooth on macOS: - Chrome: works via Cmd+[ / Cmd+] - Safari: works via AXPress on toolbar navigation button Closes: back/forward buttons not working on macOS with MX Vertical --- Cargo.lock | 2 + crates/openlogi-agent-core/Cargo.toml | 1 + .../openlogi-agent-core/src/hook_runtime.rs | 8 + .../src/watchers/gesture.rs | 15 +- crates/openlogi-hid/Cargo.toml | 4 + crates/openlogi-hid/src/gesture.rs | 179 +++++++- crates/openlogi-hid/src/gesture/tests.rs | 6 +- crates/openlogi-hid/src/reprog_controls.rs | 22 + crates/openlogi-inject/src/inject.rs | 381 +++++++++++++++++- crates/openlogi-inject/src/lib.rs | 2 +- 10 files changed, 598 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9b1b68fc..0e3edb72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4962,6 +4962,8 @@ dependencies = [ "futures-concurrency", "futures-lite 2.6.1", "num_enum", + "objc2", + "objc2-app-kit", "openlogi-core", "openlogi-hidpp", "serde", diff --git a/crates/openlogi-agent-core/Cargo.toml b/crates/openlogi-agent-core/Cargo.toml index 430a8daf..1b43b52e 100644 --- a/crates/openlogi-agent-core/Cargo.toml +++ b/crates/openlogi-agent-core/Cargo.toml @@ -28,3 +28,4 @@ workspace = true bincode = "1.3" # Only to construct an `InventoryError::Hid` in the watcher's classify tests. async-hid = { workspace = true } + diff --git a/crates/openlogi-agent-core/src/hook_runtime.rs b/crates/openlogi-agent-core/src/hook_runtime.rs index 5c45584d..521fc12f 100644 --- a/crates/openlogi-agent-core/src/hook_runtime.rs +++ b/crates/openlogi-agent-core/src/hook_runtime.rs @@ -290,6 +290,14 @@ pub fn dispatch_action( toggle_smartshift_in_background(Some(capture), target); return; } + Action::BrowserBack | Action::BrowserForward => { + // Use keyboard shortcut (Cmd+[ / Cmd+]) for browsers like Chrome that + // respond to it. Safari is handled exclusively via the HID++ gesture + // watcher path (which uses AXPress with the PID captured at press time) + // to avoid double-navigation when both paths fire for the same press. + openlogi_inject::execute(action); + None + } other => { openlogi_inject::execute(other); None diff --git a/crates/openlogi-agent-core/src/watchers/gesture.rs b/crates/openlogi-agent-core/src/watchers/gesture.rs index 80659682..b46cbd46 100644 --- a/crates/openlogi-agent-core/src/watchers/gesture.rs +++ b/crates/openlogi-agent-core/src/watchers/gesture.rs @@ -330,14 +330,25 @@ fn dispatch( debug!(?direction, "gesture with no binding — ignored"); } } - CapturedInput::ButtonPressed(button) => { + CapturedInput::ButtonPressed(button, frontmost_pid) => { let action = hook_maps .read() .ok() .and_then(|maps| maps.bindings.get(&button).cloned()); if let Some(action) = action { debug!(?button, action = %action.label(), "HID++ button → action"); - hook_runtime::dispatch_action(&action, dpi_cycle, capture); + // For browser navigation, use the AX API with the PID captured + // at press time (before async dispatch could shift focus). + let ax_handled = matches!(action, Action::BrowserBack | Action::BrowserForward) + && frontmost_pid + .map(|pid| openlogi_inject::ax_navigate_browser( + pid, + matches!(action, Action::BrowserForward), + )) + .unwrap_or(false); + if !ax_handled { + hook_runtime::dispatch_action(&action, dpi_cycle, capture); + } } else { debug!(?button, "HID++ button with no binding — ignored"); } diff --git a/crates/openlogi-hid/Cargo.toml b/crates/openlogi-hid/Cargo.toml index 2901edcb..dc5a2be0 100644 --- a/crates/openlogi-hid/Cargo.toml +++ b/crates/openlogi-hid/Cargo.toml @@ -36,5 +36,9 @@ windows-sys = { workspace = true, features = [ "Win32_Storage_FileSystem", ] } +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6.4" +objc2-app-kit = { version = "0.3.2", features = ["NSWorkspace", "NSRunningApplication"] } + [lints] workspace = true diff --git a/crates/openlogi-hid/src/gesture.rs b/crates/openlogi-hid/src/gesture.rs index 1783494b..6caaf66f 100644 --- a/crates/openlogi-hid/src/gesture.rs +++ b/crates/openlogi-hid/src/gesture.rs @@ -16,6 +16,7 @@ //! is therefore only diverted when its click is actually bound. use std::sync::{Arc, Mutex, PoisonError, RwLock}; +use std::time::{Duration, Instant}; use hidpp::{channel::HidppChannel, device::Device, protocol::v20}; use openlogi_core::binding::{ButtonId, GestureDirection, SwipeAccumulator}; @@ -29,20 +30,45 @@ use crate::route::{DeviceRoute, open_route_channel}; use crate::thumbwheel::{self, Thumbwheel}; use crate::write::SharedChannel; +/// Return the PID of the frontmost application at this instant. +/// Called on the HID++ listener thread — before any async dispatch delay — +/// so the PID reflects the app that was active when the button was pressed. +/// Returns `None` on non-macOS platforms or if no frontmost app exists. +fn frontmost_pid() -> Option { + #[cfg(target_os = "macos")] + { + use objc2::rc::autoreleasepool; + use objc2_app_kit::NSWorkspace; + autoreleasepool(|_| { + NSWorkspace::sharedWorkspace() + .frontmostApplication() + .map(|a| a.processIdentifier()) + }) + } + #[cfg(not(target_os = "macos"))] + { + None + } +} + /// Shared slot holding the active capture session's open channel, so DPI / /// SmartShift writes can reuse it instead of opening a fresh one. `None` /// whenever no session is connected. pub type CaptureChannel = Arc>>; /// One input captured from the active device. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum CapturedInput { /// A completed gesture-button swipe. Gesture(GestureDirection), /// A diverted button was pressed — the DPI/ModeShift button /// ([`ButtonId::DpiToggle`]) or the thumb-wheel single tap - /// ([`ButtonId::Thumbwheel`]). - ButtonPressed(ButtonId), + /// ([`ButtonId::Thumbwheel`]). The optional `frontmost_pid` is the + /// PID of the frontmost application at the instant the button was pressed + /// (captured on the listener thread to avoid timing races on dispatch). + /// The PID is skipped in serialization — it is a dispatch hint, not part + /// of the stable wire format. + ButtonPressed(ButtonId, #[serde(skip)] Option), /// Thumb-wheel rotation to re-synthesise as horizontal scroll, in the /// wheel's `diverted_res` increments. Emitted only while the wheel is /// diverted to capture its click. @@ -75,8 +101,31 @@ struct CaptureAccum { /// Whether any DPI/ModeShift control was held in the last event — for /// rising-edge press detection. dpi_down: bool, + /// Whether any Back control was held in the last event. + back_down: bool, + /// Whether any Forward control was held in the last event. + forward_down: bool, + /// Timestamp of the last Back press dispatch — for debounce. + last_back: Option, + /// Timestamp of the last Forward press dispatch — for debounce. + last_forward: Option, } +/// Minimum time between two Back or Forward dispatches from the same HID++ +/// CID. The MX Vertical sends multiple DivertedButtons frames per physical +/// click as the CID flag bounces in/out within a single press (~50-100ms). +/// 150ms suppresses intra-press bounce while allowing intentional rapid +/// double-clicks (typically ≥200ms apart). +/// +/// Note: on devices that expose buttons through both HID++ diversion and the +/// OS CGEventTap path (e.g. MX Vertical), a single press can fire both the +/// gesture watcher and the hook. The gesture watcher uses AXPress (Safari-safe); +/// the hook path uses Cmd+[/] (Chrome-safe, no-op in Safari). This is harmless +/// in practice — Safari only responds to AXPress, Chrome only responds to +/// keyboard shortcuts, and the two actions don't double-navigate. A shared +/// cross-path debounce (`TODO`) would be cleaner but is not required. +const BACK_FORWARD_DEBOUNCE: Duration = Duration::from_millis(150); + /// Capture the gesture button, DPI/ModeShift button, and (when /// `capture_thumbwheel`) the thumb wheel on `route` until `shutdown` resolves, /// forwarding each event to `sink`. @@ -122,6 +171,8 @@ pub async fn run_capture_session( let reprog_index = armed.reprog.as_ref().map(|(_, idx)| *idx); let thumb_index = armed.thumb.as_ref().map(|(_, idx)| *idx); let dpi_set = armed.dpi_cids.clone(); + let back_set = armed.back_cids.clone(); + let forward_set = armed.forward_cids.clone(); let listener = chan.add_msg_listener_guarded({ let accum = Arc::clone(&accum); let sink = sink.clone(); @@ -136,14 +187,14 @@ pub async fn run_capture_session( // Recover the guard even if a prior holder panicked — the // critical section is panic-free, so the data is consistent. let mut acc = accum.lock().unwrap_or_else(PoisonError::into_inner); - handle_reprog(&mut acc, event, &dpi_set, &sink); + handle_reprog(&mut acc, event, &dpi_set, &back_set, &forward_set, &sink); return; } if let Some(idx) = thumb_index && let Some(event) = thumbwheel::decode_event(&msg, device_index, idx) { if event.single_tap { - let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::Thumbwheel)); + let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::Thumbwheel, None)); } if event.rotation != 0 { let _ = sink.send(CapturedInput::Scroll(event.rotation)); @@ -156,6 +207,8 @@ pub async fn run_capture_session( index = device_index, gesture = armed.gesture_diverted, dpi_buttons = armed.dpi_cids.len(), + back_buttons = armed.back_cids.len(), + forward_buttons = armed.forward_cids.len(), thumbwheel = armed.thumb.is_some(), "control capture active" ); @@ -179,6 +232,10 @@ struct ArmedControls { gesture_diverted: bool, /// DPI/ModeShift CIDs diverted as plain buttons. dpi_cids: Vec, + /// Back button CIDs diverted as plain buttons. + back_cids: Vec, + /// Forward button CIDs diverted as plain buttons. + forward_cids: Vec, /// `0x2150` accessor + feature index, present when the thumb wheel is /// diverted. thumb: Option<(Thumbwheel, u8)>, @@ -197,6 +254,15 @@ impl ArmedControls { for &cid in &self.dpi_cids { restore(rc.set_cid_reporting(cid, false, false).await, "DPI button"); } + for &cid in &self.back_cids { + restore(rc.set_cid_reporting(cid, false, false).await, "Back button"); + } + for &cid in &self.forward_cids { + restore( + rc.set_cid_reporting(cid, false, false).await, + "Forward button", + ); + } } if let Some((tw, _)) = self.thumb.as_ref() { restore(tw.set_reporting(false, false).await, "thumb wheel"); @@ -222,6 +288,8 @@ async fn arm_controls( let mut reprog: Option<(ReprogControlsV4, u8)> = None; let mut gesture_diverted = false; let mut dpi_cids: Vec = Vec::new(); + let mut back_cids: Vec = Vec::new(); + let mut forward_cids: Vec = Vec::new(); if let Some(info) = device .root() .get_feature(reprog_controls::FEATURE_ID) @@ -238,6 +306,7 @@ async fn arm_controls( .iter() .any(|c| c.cid == reprog_controls::GESTURE_BUTTON_CID && c.supports_raw_xy()) { + // No prior diversions to roll back at this point — gesture is first. rc.set_cid_reporting(reprog_controls::GESTURE_BUTTON_CID, true, true) .await .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?; @@ -245,12 +314,55 @@ async fn arm_controls( } for &cid in &reprog_controls::DPI_MODE_SHIFT_CIDS { if controls.iter().any(|c| c.cid == cid && c.is_divertable()) { - rc.set_cid_reporting(cid, true, false) - .await - .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?; + if let Err(e) = rc.set_cid_reporting(cid, true, false).await { + // Roll back gesture diversion (the only prior diversion). + if gesture_diverted { + let _ = rc.set_cid_reporting(reprog_controls::GESTURE_BUTTON_CID, false, false).await; + } + for &diverted in &dpi_cids { + let _ = rc.set_cid_reporting(diverted, false, false).await; + } + return Err(GestureError::Hidpp(format!("{e:?}"))); + } dpi_cids.push(cid); } } + // Back/Forward buttons on MX Vertical and similar devices report via + // HID++ rather than as standard OS mouse buttons. Divert them so the + // capture session can synthesize the correct OS events. + // Track progress so any already-diverted CIDs are restored if a later + // set_cid_reporting call fails (avoids leaving buttons stuck). + for &cid in &reprog_controls::BACK_CIDS { + if controls.iter().any(|c| c.cid == cid && c.is_divertable()) { + if let Err(e) = rc.set_cid_reporting(cid, true, false).await { + // Roll back ALL already-diverted CIDs (gesture, DPI, and Back) + // before propagating, so no buttons are left stuck diverted. + if gesture_diverted { + let _ = rc.set_cid_reporting(reprog_controls::GESTURE_BUTTON_CID, false, false).await; + } + for &diverted in dpi_cids.iter().chain(back_cids.iter()) { + let _ = rc.set_cid_reporting(diverted, false, false).await; + } + return Err(GestureError::Hidpp(format!("{e:?}"))); + } + back_cids.push(cid); + } + } + for &cid in &reprog_controls::FORWARD_CIDS { + if controls.iter().any(|c| c.cid == cid && c.is_divertable()) { + if let Err(e) = rc.set_cid_reporting(cid, true, false).await { + // Roll back all already-diverted CIDs. + if gesture_diverted { + let _ = rc.set_cid_reporting(reprog_controls::GESTURE_BUTTON_CID, false, false).await; + } + for &diverted in dpi_cids.iter().chain(back_cids.iter()).chain(forward_cids.iter()) { + let _ = rc.set_cid_reporting(diverted, false, false).await; + } + return Err(GestureError::Hidpp(format!("{e:?}"))); + } + forward_cids.push(cid); + } + } reprog = Some((rc, info.index)); } @@ -274,22 +386,28 @@ async fn arm_controls( } }; if supports_single_tap { - tw.set_reporting(true, false) - .await - .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?; - thumb = Some((tw, info.index)); + // Use warn+continue rather than ? so a thumbwheel setup failure + // doesn't abort the whole session and leave already-diverted + // Back/Forward/DPI controls stuck with no capture session to + // restore them. + match tw.set_reporting(true, false).await { + Ok(()) => thumb = Some((tw, info.index)), + Err(e) => warn!(error = ?e, "thumb wheel set_reporting failed — skipping click capture"), + } } else { debug!("thumb wheel reports no single tap — click not capturable"); } } - if !gesture_diverted && dpi_cids.is_empty() && thumb.is_none() { + if !gesture_diverted && dpi_cids.is_empty() && back_cids.is_empty() && forward_cids.is_empty() && thumb.is_none() { debug!(slot, "no capturable controls — idle session"); } Ok(ArmedControls { reprog, gesture_diverted, dpi_cids, + back_cids, + forward_cids, thumb, }) } @@ -329,6 +447,8 @@ fn handle_reprog( acc: &mut CaptureAccum, event: RawControlEvent, dpi_cids: &[u16], + back_cids: &[u16], + forward_cids: &[u16], sink: &mpsc::UnboundedSender, ) { match event { @@ -346,9 +466,40 @@ fn handle_reprog( let dpi_down = dpi_cids.iter().any(|cid| cids.contains(cid)); if dpi_down && !acc.dpi_down { - let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::DpiToggle)); + let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::DpiToggle, None)); } acc.dpi_down = dpi_down; + + // Back/Forward: emit on the rising edge (first frame where the CID + // appears), matching the DPI button convention above. + let back_down = back_cids.iter().any(|cid| cids.contains(cid)); + if back_down && !acc.back_down { + let now = Instant::now(); + let elapsed = acc.last_back.map_or(BACK_FORWARD_DEBOUNCE, |t| now - t); + if elapsed >= BACK_FORWARD_DEBOUNCE { + acc.last_back = Some(now); + // Capture frontmost PID NOW on this listener thread — before + // any async dispatch delay can shift focus away from the + // target browser window. + let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::Back, frontmost_pid())); + } else { + debug!(elapsed_ms = elapsed.as_millis(), "Back debounced — too soon after last dispatch"); + } + } + acc.back_down = back_down; + + let forward_down = forward_cids.iter().any(|cid| cids.contains(cid)); + if forward_down && !acc.forward_down { + let now = Instant::now(); + let elapsed = acc.last_forward.map_or(BACK_FORWARD_DEBOUNCE, |t| now - t); + if elapsed >= BACK_FORWARD_DEBOUNCE { + acc.last_forward = Some(now); + let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::Forward, frontmost_pid())); + } else { + debug!(elapsed_ms = elapsed.as_millis(), "Forward debounced — too soon after last dispatch"); + } + } + acc.forward_down = forward_down; } RawControlEvent::RawXy { dx, dy } => { // Commit the instant a clean direction emerges (mid-swipe, once per diff --git a/crates/openlogi-hid/src/gesture/tests.rs b/crates/openlogi-hid/src/gesture/tests.rs index 035f2cda..a96d8162 100644 --- a/crates/openlogi-hid/src/gesture/tests.rs +++ b/crates/openlogi-hid/src/gesture/tests.rs @@ -71,7 +71,7 @@ fn a_held_dpi_button_presses_once_on_the_rising_edge() { assert_eq!( rx.try_recv(), - Ok(CapturedInput::ButtonPressed(ButtonId::DpiToggle)) + Ok(CapturedInput::ButtonPressed(ButtonId::DpiToggle, None)) ); assert!(rx.try_recv().is_err(), "a held DPI button presses once"); } @@ -93,11 +93,11 @@ fn a_dpi_button_re_presses_after_a_release() { assert_eq!( rx.try_recv(), - Ok(CapturedInput::ButtonPressed(ButtonId::DpiToggle)) + Ok(CapturedInput::ButtonPressed(ButtonId::DpiToggle, None)) ); assert_eq!( rx.try_recv(), - Ok(CapturedInput::ButtonPressed(ButtonId::DpiToggle)), + Ok(CapturedInput::ButtonPressed(ButtonId::DpiToggle, None)), "a release re-arms the rising edge" ); assert!(rx.try_recv().is_err()); diff --git a/crates/openlogi-hid/src/reprog_controls.rs b/crates/openlogi-hid/src/reprog_controls.rs index c25d4e3b..6c3261da 100644 --- a/crates/openlogi-hid/src/reprog_controls.rs +++ b/crates/openlogi-hid/src/reprog_controls.rs @@ -51,6 +51,28 @@ pub const GESTURE_BUTTON_CID: u16 = 0x00c3; /// cross-checked against Solaar `special_keys.py`. pub const DPI_MODE_SHIFT_CIDS: [u16; 3] = [0x00c4, 0x00ed, 0x00fd]; +/// Control IDs of the Back button family. MX Vertical and similar devices +/// report Back via HID++ `0x1b04` rather than a standard OS mouse button, +/// so macOS never translates them into `OtherMouseDown` events. Whichever a +/// device exposes (and can divert) is captured and mapped to +/// [`ButtonId::Back`](openlogi_core::binding::ButtonId::Back). +/// +/// Known CIDs (from the `0x1b04` control-ID list / Solaar `special_keys.py`): +/// - `0x0053` — Back (classic mouse CID, used by MX Vertical) +/// - `0x00BD` — MultiPlatform Back +/// - `0x00CE` — Multiplatform Back (alternate) +/// - `0x00DB` — Back (generic) +pub const BACK_CIDS: [u16; 4] = [0x0053, 0x00BD, 0x00CE, 0x00DB]; + +/// Control IDs of the Forward button family. Counterpart to [`BACK_CIDS`]: +/// captured and mapped to +/// [`ButtonId::Forward`](openlogi_core::binding::ButtonId::Forward). +/// +/// Known CIDs: +/// - `0x0056` — Forward (classic mouse CID, used by MX Vertical) +/// - `0x00CF` — Multiplatform Forward +pub const FORWARD_CIDS: [u16; 2] = [0x0056, 0x00CF]; + /// Identity and capabilities of one reprogrammable control, as returned by /// `getCtrlIdInfo`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/openlogi-inject/src/inject.rs b/crates/openlogi-inject/src/inject.rs index 48f8c603..480c5387 100644 --- a/crates/openlogi-inject/src/inject.rs +++ b/crates/openlogi-inject/src/inject.rs @@ -195,8 +195,10 @@ fn execute_macos(action: &Action) { Action::Find => macos::post_key(VK_F, cmd), Action::Save => macos::post_key(VK_S, cmd), // ── Browser / Navigation ────────────────────────────────────────── - // BrowserBack/Forward: Cmd+[ / Cmd+] as keyboard fallback; hook - // layer handles the physical mouse buttons directly. + // BrowserBack/Forward: Cmd+[ / Cmd+] for Chrome and other apps. + // Safari is handled upstream via ax_navigate_browser() with the PID + // captured at press time — by the time execute() is called the AX path + // has already run, so this fallback is for non-Safari browsers only. // kVK_ANSI_LeftBracket = 0x21, kVK_ANSI_RightBracket = 0x1E Action::BrowserBack => macos::post_key(0x21, cmd), Action::BrowserForward => macos::post_key(0x1E, cmd), @@ -355,6 +357,26 @@ fn execute_windows(action: &Action) { /// rotation convention and its magnitude (one line per rotation increment) may /// need tuning per device, since the diverted resolution differs from native. /// +/// No-op (logs nothing) on platforms without a supported injection mechanism. +/// Navigate the browser identified by `pid` backwards or forwards using the +/// Accessibility API (`AXPress` on the "Go back" / "Go forward" toolbar button). +/// +/// Call this from the gesture watcher **at the moment the button press arrives** +/// so `pid` reflects the correct frontmost app rather than whatever happens to +/// be frontmost when the async dispatch completes. Returns `true` on success. +/// No-op (returns `false`) on non-macOS platforms. +pub fn ax_navigate_browser(pid: i32, forward: bool) -> bool { + #[cfg(target_os = "macos")] + { + macos::ax_browser_navigate(forward, Some(pid)) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (pid, forward); + false + } +} + /// No-op (logs nothing) on platforms without a supported injection mechanism. pub fn post_horizontal_scroll(delta: i32) { #[cfg(target_os = "macos")] @@ -612,6 +634,361 @@ mod macos { ev.post(CGEventTapLocation::HID); } + /// Press the Back (`forward=false`) or Forward (`forward=true`) navigation + /// button in the frontmost application via the Accessibility API. + /// + /// Safari's WKWebView ignores synthetic `CGEvent` mouse-button and keyboard + /// events posted at the HID or Session tap levels. However it does respond + /// correctly to `AXPress` on its toolbar's "Go back" / "Go forward" button, + /// because that path goes through AppKit's normal action dispatch rather than + /// the input event pipeline. + /// + /// Returns `true` when an AX button was found and pressed (result `kAXErrorSuccess`), + /// `false` on any failure — the caller should fall back to a keyboard shortcut. + #[allow(unsafe_code, reason = "AXUIElement / CF APIs require raw FFI")] + pub(super) fn ax_browser_navigate(forward: bool, pid: Option) -> bool { + use objc2::rc::autoreleasepool; + use objc2_app_kit::NSWorkspace; + use std::ffi::c_void; + + type AXUIElementRef = *const c_void; + type CFTypeRef = *const c_void; + + #[link(name = "ApplicationServices", kind = "framework")] + unsafe extern "C" { + fn AXUIElementCreateApplication(pid: i32) -> AXUIElementRef; + fn AXUIElementCopyAttributeValue( + element: AXUIElementRef, + attribute: core_foundation::string::CFStringRef, + value: *mut CFTypeRef, + ) -> i32; + fn AXUIElementPerformAction( + element: AXUIElementRef, + action: core_foundation::string::CFStringRef, + ) -> i32; + fn CFRelease(cf: CFTypeRef); + fn CFGetTypeID(cf: CFTypeRef) -> usize; + fn CFArrayGetTypeID() -> usize; + fn CFArrayGetCount(arr: CFTypeRef) -> isize; + fn CFArrayGetValueAtIndex(arr: CFTypeRef, idx: isize) -> CFTypeRef; + fn CFRetain(cf: CFTypeRef) -> CFTypeRef; + } + + const AX_ERROR_SUCCESS: i32 = 0; + + use core_foundation::base::TCFType as _; + use core_foundation::string::CFString; + + let attr_focused_window = CFString::new("AXFocusedWindow"); + let attr_children = CFString::new("AXChildren"); + let attr_role = CFString::new("AXRole"); + let attr_description = CFString::new("AXDescription"); + let attr_identifier = CFString::new("AXIdentifier"); + let attr_subrole = CFString::new("AXSubrole"); + let ax_press = CFString::new("AXPress"); + // AXIdentifier is locale-independent (Safari sets these stable IDs on + // its toolbar navigation buttons). Description ("Go back"/"Go forward") + // is locale-dependent and will fail on non-English systems. + let target_identifier = if forward { + "BackForwardToolbarButton_Forward" + } else { + "BackForwardToolbarButton_Back" + }; + // AXSubrole is also locale-independent and may be set on some Safari versions. + let target_subrole = if forward { + "AXBackForwardButtonForward" + } else { + "AXBackForwardButtonBack" + }; + // Last-resort English description fallback for older Safari/macOS versions. + let target_desc_en = if forward { "Go forward" } else { "Go back" }; + + // SAFETY throughout: all AXUIElement/CF calls follow the CF memory rules + // (Get Rule = no extra retain; Create/Copy Rule = +1 retain, caller releases). + + /// Get one AX attribute as a raw CFTypeRef (+1 retained). Caller must CFRelease. + unsafe fn copy_attr( + el: AXUIElementRef, + attr: core_foundation::string::CFStringRef, + ) -> Option { + let mut val: CFTypeRef = std::ptr::null(); + let err = unsafe { AXUIElementCopyAttributeValue(el, attr, &raw mut val) }; + if err == 0 && !val.is_null() { Some(val) } else { None } + } + + /// Read an AX attribute as a String. Internally copies + releases. + unsafe fn attr_string( + el: AXUIElementRef, + attr: core_foundation::string::CFStringRef, + ) -> Option { + let val = unsafe { copy_attr(el, attr) }?; + // SAFETY: AX string attributes return CFStringRef. + let s = unsafe { CFString::wrap_under_create_rule(val.cast()) }; + Some(s.to_string()) + } + + /// Walk the AX tree looking for an AXButton whose AXDescription equals + /// `target`. Returns the element pointer (NOT retained — it is owned by + /// its parent's AXChildren array which we do not retain either). The + /// caller must press it before the parent arrays are released. + unsafe fn find_button( + el: AXUIElementRef, + target_id: &str, + target_subrole: &str, + target_desc: &str, + attr_role: core_foundation::string::CFStringRef, + attr_desc: core_foundation::string::CFStringRef, + attr_identifier: core_foundation::string::CFStringRef, + attr_subrole: core_foundation::string::CFStringRef, + attr_children: core_foundation::string::CFStringRef, + depth: u8, + ) -> Option { + if depth == 0 { + return None; + } + // Check if this element is the button we want. + if let Some(role_val) = unsafe { copy_attr(el, attr_role) } { + let role_s = unsafe { CFString::wrap_under_create_rule(role_val.cast()) }.to_string(); + // Skip tab-bar elements — AXSplitGroup, AXTabGroup, AXOpaqueProviderGroup, + // AXRadioButton — to avoid wasting depth on Safari's 89-tab bar before + // reaching the toolbar navigation buttons. + let skip = matches!( + role_s.as_str(), + "AXSplitGroup" | "AXTabGroup" | "AXOpaqueProviderGroup" | "AXRadioButton" + ); + if skip { + return None; + } + let is_button = role_s == "AXButton"; + if is_button { + // 1. AXIdentifier — locale-independent, preferred. + if let Some(ident) = unsafe { attr_string(el, attr_identifier) } { + if ident == target_id { + // CFRetain here (only once, at the leaf) so callers + // can release the children arrays without dangling. + return Some(unsafe { CFRetain(el) }); + } + } + // 2. AXSubrole — locale-independent, set on some Safari versions. + if let Some(sr) = unsafe { attr_string(el, attr_subrole) } { + if sr == target_subrole { + return Some(unsafe { CFRetain(el) }); + } + } + // 3. AXDescription — locale-dependent last resort. + if let Some(desc) = unsafe { attr_string(el, attr_desc) } { + if desc == target_desc { + return Some(unsafe { CFRetain(el) }); + } + } + return None; // don't recurse into buttons + } + } + // Recurse into AXChildren. + let children_val = unsafe { copy_attr(el, attr_children) }?; + // Verify it's actually a CFArray before treating it as one. + let is_array = unsafe { CFGetTypeID(children_val) == CFArrayGetTypeID() }; + if !is_array { + unsafe { CFRelease(children_val) }; + return None; + } + let count = unsafe { CFArrayGetCount(children_val) }; + let mut found: Option = None; + for i in 0..count { + // Get Rule — not retained. + let child = unsafe { CFArrayGetValueAtIndex(children_val, i) }; + if child.is_null() { + continue; + } + if let Some(f) = unsafe { find_button( + child, + target_id, + target_subrole, + target_desc, + attr_role, + attr_desc, + attr_identifier, + attr_subrole, + attr_children, + depth - 1, + ) } { + found = Some(f); + break; + } + } + // found is already +1 retained (CFRetain'd at the leaf in the button + // check above). Parent frames propagate it without re-retaining. + // Safe to release the children array now. + unsafe { CFRelease(children_val) }; + found + } + + /// Positional fallback: locate the Back (idx=0) or Forward (idx=1) button + /// by structure rather than by attribute text. The Safari toolbar layout is: + /// AXWindow → AXToolbar → AXGroup[1] → AXGroup[0] → AXButton[0/1] + /// This is locale-independent and works when no AX attribute names the button. + unsafe fn find_nav_button_by_position( + win: AXUIElementRef, + forward: bool, + attr_role: core_foundation::string::CFStringRef, + attr_children: core_foundation::string::CFStringRef, + ) -> Option { + // Helper: get children as a raw CFArray (caller must CFRelease) + let children_of = |el: AXUIElementRef| -> Option { + let mut val: CFTypeRef = std::ptr::null(); + let err = AXUIElementCopyAttributeValue(el, attr_children, &raw mut val); + if err == 0 && !val.is_null() { Some(val) } else { None } + }; + let role_of = |el: AXUIElementRef| -> Option { + let mut val: CFTypeRef = std::ptr::null(); + let err = AXUIElementCopyAttributeValue(el, attr_role, &raw mut val); + if err != 0 || val.is_null() { return None; } + let s = CFString::wrap_under_create_rule(val.cast()).to_string(); + Some(s) + }; + let child_at = |arr: CFTypeRef, idx: isize| -> Option { + if CFArrayGetCount(arr) <= idx { return None; } + let c = CFArrayGetValueAtIndex(arr, idx); + if c.is_null() { None } else { Some(c) } + }; + + // AXWindow children: find AXToolbar + let win_kids = children_of(win)?; + let count = CFArrayGetCount(win_kids); + let mut toolbar: Option = None; + for i in 0..count { + if let Some(c) = child_at(win_kids, i) { + if role_of(c).as_deref() == Some("AXToolbar") { + toolbar = Some(c); + break; + } + } + } + CFRelease(win_kids); + let toolbar = toolbar?; + + // AXToolbar children: skip AXGroups until we find the nav group + // (the group whose first child is itself an AXGroup containing buttons). + let tb_kids = children_of(toolbar)?; + let tb_count = CFArrayGetCount(tb_kids); + let mut nav_group: Option = None; + for i in 0..tb_count { + if let Some(g) = child_at(tb_kids, i) { + if role_of(g).as_deref() != Some("AXGroup") { continue; } + // Check if its first child is also an AXGroup (the inner nav group) + if let Some(inner_kids) = children_of(g) { + let has_inner = child_at(inner_kids, 0) + .and_then(|c| role_of(c)) + .as_deref() == Some("AXGroup"); + CFRelease(inner_kids); + if has_inner { + nav_group = Some(g); + break; + } + } + } + } + CFRelease(tb_kids); + let nav_group = nav_group?; + + // nav_group → first AXGroup child → AXButton[0 or 1] + let ng_kids = children_of(nav_group)?; + let inner = child_at(ng_kids, 0); + CFRelease(ng_kids); + let inner = inner?; + + let inner_kids = children_of(inner)?; + let btn_idx = if forward { 1 } else { 0 }; + let btn = child_at(inner_kids, btn_idx); + CFRelease(inner_kids); + let btn = btn?; + + if role_of(btn).as_deref() == Some("AXButton") { + Some(CFRetain(btn)) + } else { + None + } + } + + autoreleasepool(|_| { + let resolved_pid = if let Some(p) = pid { + p + } else { + NSWorkspace::sharedWorkspace() + .frontmostApplication()? + .processIdentifier() + }; + // SAFETY: returns +1 retained AXUIElement. + let app_ax = unsafe { AXUIElementCreateApplication(resolved_pid) }; + if app_ax.is_null() { + return None::<()>; + } + + // Get focused window (+1 retained). + let win = unsafe { + copy_attr(app_ax, attr_focused_window.as_concrete_TypeRef()) + }; + // SAFETY: balance +1 from AXUIElementCreateApplication. + unsafe { CFRelease(app_ax) }; + let win = win?; + + // Find the nav button (borrowed pointer inside the window's tree). + let button = unsafe { + find_button( + win, + target_identifier, + target_subrole, + target_desc_en, + attr_role.as_concrete_TypeRef(), + attr_description.as_concrete_TypeRef(), + attr_identifier.as_concrete_TypeRef(), + attr_subrole.as_concrete_TypeRef(), + attr_children.as_concrete_TypeRef(), + 6, + ) + // Positional fallback: if identifier/subrole/description all failed + // (e.g. non-English Safari without AXIdentifier), find the nav group + // by structure — second AXGroup of AXToolbar, first sub-group, then + // pick button 0 (back) or button 1 (forward). + .or_else(|| find_nav_button_by_position( + win, + forward, + attr_role.as_concrete_TypeRef(), + attr_children.as_concrete_TypeRef(), + )) + }; + + let result = button.map(|btn| { + // SAFETY: btn is a +1 retained AXUIElement (CFRetain'd by find_button). + let r = unsafe { + AXUIElementPerformAction(btn, ax_press.as_concrete_TypeRef()) + }; + // SAFETY: balance the CFRetain from find_button. + unsafe { CFRelease(btn) }; + r == AX_ERROR_SUCCESS + }); + + // SAFETY: balance +1 from copy_attr (focused window). + unsafe { CFRelease(win) }; + + match result { + Some(true) => { + tracing::debug!(forward, "AX browser navigate succeeded"); + Some(()) + } + Some(false) => { + tracing::debug!(forward, "AX browser navigate: AXPress failed"); + None + } + None => { + tracing::debug!(forward, "AX browser navigate: button not found"); + None + } + } + }) + .is_some() + } + pub(super) use dock::{app_expose, launchpad, mission_control, show_desktop}; pub(super) use symbolic_hotkey::{next_desktop, previous_desktop}; diff --git a/crates/openlogi-inject/src/lib.rs b/crates/openlogi-inject/src/lib.rs index 347b7605..ed56d01b 100644 --- a/crates/openlogi-inject/src/lib.rs +++ b/crates/openlogi-inject/src/lib.rs @@ -2,7 +2,7 @@ mod inject; -pub use inject::{SYNTHETIC_EVENT_USER_DATA, execute, post_horizontal_scroll}; +pub use inject::{SYNTHETIC_EVENT_USER_DATA, ax_navigate_browser, execute, post_horizontal_scroll}; #[cfg(target_os = "linux")] pub use inject::action_device_path;