Skip to content
Open
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: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/openlogi-agent-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

8 changes: 8 additions & 0 deletions crates/openlogi-agent-core/src/hook_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions crates/openlogi-agent-core/src/watchers/gesture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
4 changes: 4 additions & 0 deletions crates/openlogi-hid/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
179 changes: 165 additions & 14 deletions crates/openlogi-hid/src/gesture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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<i32> {
#[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<RwLock<Option<SharedChannel>>>;

/// 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<i32>),
/// 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.
Expand Down Expand Up @@ -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<Instant>,
/// Timestamp of the last Forward press dispatch — for debounce.
last_forward: Option<Instant>,
}

/// 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`.
Expand Down Expand Up @@ -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();
Expand All @@ -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));
Expand All @@ -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"
);
Expand All @@ -179,6 +232,10 @@ struct ArmedControls {
gesture_diverted: bool,
/// DPI/ModeShift CIDs diverted as plain buttons.
dpi_cids: Vec<u16>,
/// Back button CIDs diverted as plain buttons.
back_cids: Vec<u16>,
/// Forward button CIDs diverted as plain buttons.
forward_cids: Vec<u16>,
/// `0x2150` accessor + feature index, present when the thumb wheel is
/// diverted.
thumb: Option<(Thumbwheel, u8)>,
Expand All @@ -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");
Expand All @@ -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<u16> = Vec::new();
let mut back_cids: Vec<u16> = Vec::new();
let mut forward_cids: Vec<u16> = Vec::new();
if let Some(info) = device
.root()
.get_feature(reprog_controls::FEATURE_ID)
Expand All @@ -238,19 +306,63 @@ 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:?}")))?;
gesture_diverted = true;
}
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;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return Err(GestureError::Hidpp(format!("{e:?}")));
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
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);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
reprog = Some((rc, info.index));
}

Expand All @@ -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,
})
}
Expand Down Expand Up @@ -329,6 +447,8 @@ fn handle_reprog(
acc: &mut CaptureAccum,
event: RawControlEvent,
dpi_cids: &[u16],
back_cids: &[u16],
forward_cids: &[u16],
sink: &mpsc::UnboundedSender<CapturedInput>,
) {
match event {
Expand All @@ -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
Expand Down
Loading