Skip to content
Draft
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
1 change: 1 addition & 0 deletions Cargo.lock

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

230 changes: 141 additions & 89 deletions crates/openlogi-agent-core/src/hook_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ use std::sync::{Arc, RwLock};
use openlogi_core::binding::{
Action, ButtonId, GestureDirection, SwipeAccumulator, default_binding,
};
use openlogi_core::config::{KeyModifiers, KeyTrigger};
use openlogi_hid::CaptureChannel;
use openlogi_hook::{EventDisposition, Hook, MouseEvent};
use openlogi_hook::{EventDisposition, Hook, HookEvent, MouseEvent};
use tracing::{info, warn};

use crate::DpiCycleState;
Expand All @@ -38,6 +39,24 @@ pub struct HookMaps {
/// (orchestrator), the OS-hook callback, and the gesture watcher.
pub type SharedHookMaps = Arc<RwLock<HookMaps>>;

/// Shared keyboard trigger→action map for the function-key remapper. Unlike
/// mouse bindings these are not per-app-profile (M1 scope — per the spec's
/// non-goals), so a single map suffices. Keyed by the config `KeyTrigger`
/// (keycode + modifiers).
pub type SharedKeyboardBindings = Arc<RwLock<std::collections::HashMap<KeyTrigger, Action>>>;

/// Convert the hook-layer modifier state into the config-layer type (the two
/// live in different crates — core is leaf-level and duplicates the four
/// bools). Drop-in identity once the field names align.
fn convert_modifiers(m: openlogi_hook::KeyModifiers) -> KeyModifiers {
KeyModifiers {
shift: m.shift,
control: m.control,
option: m.option,
command: m.command,
}
}

/// Tracks which OS-hook button (Middle/Back/Forward) is mid-hold and defers the
/// swipe detection itself to a shared [`SwipeAccumulator`], which commits a swipe
/// *mid-motion* like the HID++ gesture-button path in `openlogi-hid`. This wrapper
Expand Down Expand Up @@ -99,6 +118,7 @@ thread_local! {
/// granted or on an unsupported platform — the app continues without crashing.
pub fn start(
hooks: SharedHookMaps,
keyboard_bindings: SharedKeyboardBindings,
dpi_cycle: Arc<RwLock<DpiCycleState>>,
capture: CaptureChannel,
) -> Option<Hook> {
Expand All @@ -113,113 +133,145 @@ pub fn start(
// The per-hold pointer accumulator lives in the thread-local `HOLD`; the
// callback must never block — see the freeze-hazard note in `macos.rs`.
let result = Hook::start(move |event| match event {
MouseEvent::Button { id, pressed } => {
// The CGEventTap only sees standard buttons 0-4. We remap
// Middle/Back/Forward; the primary L/R clicks always pass through
// (suppressing them would brick the mouse), and the DPI / thumb /
// dedicated gesture button aren't visible to the tap at all — the
// dedicated gesture button is captured separately over HID++.
if !id.is_os_hook_button() {
return EventDisposition::PassThrough;
}

// Gesture button: suppress the native click and begin a hold. The
// swipe commits mid-motion in the `Moved` arm; here, on release, we
// only fire the plain `Click` when no swipe committed. The cursor is
// free to drift via the pass-through `Moved` events during the hold.
if pressed {
let is_gesture = hooks.read().is_ok_and(|m| m.gestures.contains_key(&id));
if is_gesture {
HOLD.with_borrow_mut(|h| h.begin(id));
return EventDisposition::Suppress;
HookEvent::Mouse(mouse_event) => match mouse_event {
MouseEvent::Button { id, pressed } => {
// The CGEventTap only sees standard buttons 0-4. We remap
// Middle/Back/Forward; the primary L/R clicks always pass through
// (suppressing them would brick the mouse), and the DPI / thumb /
// dedicated gesture button aren't visible to the tap at all — the
// dedicated gesture button is captured separately over HID++.
if !id.is_os_hook_button() {
return EventDisposition::PassThrough;
}
} else {
// Release: end the hold and release the `HOLD` borrow *before* any
// dispatch — the callback must stay lock-light, since a
// synthesized event could otherwise re-enter the tap and re-borrow
// `HOLD` (a RefCell double-borrow panic, freeze hazard).
let ended = HOLD.with_borrow_mut(|h| h.end(id));
if let Some(was_click) = ended {
if was_click {
// No swipe committed → fire the plain click. Resolve to an
// owned action (so no lock is held across dispatch), then
// dispatch with the guard already dropped.
let action = hooks
.read()
.ok()
.map(|m| resolve_gesture_click(&m.gestures, id));
if let Some(action) = action {
info!(button = %id, action = %action.label(), "gesture click → executing bound action");
dispatch_action(&action, &dpi_cycle, &capture);

// Gesture button: suppress the native click and begin a hold. The
// swipe commits mid-motion in the `Moved` arm; here, on release, we
// only fire the plain `Click` when no swipe committed. The cursor is
// free to drift via the pass-through `Moved` events during the hold.
if pressed {
let is_gesture = hooks.read().is_ok_and(|m| m.gestures.contains_key(&id));
if is_gesture {
HOLD.with_borrow_mut(|h| h.begin(id));
return EventDisposition::Suppress;
}
} else {
// Release: end the hold and release the `HOLD` borrow *before* any
// dispatch — the callback must stay lock-light, since a
// synthesized event could otherwise re-enter the tap and re-borrow
// `HOLD` (a RefCell double-borrow panic, freeze hazard).
let ended = HOLD.with_borrow_mut(|h| h.end(id));
if let Some(was_click) = ended {
if was_click {
// No swipe committed → fire the plain click. Resolve to an
// owned action (so no lock is held across dispatch), then
// dispatch with the guard already dropped.
let action = hooks
.read()
.ok()
.map(|m| resolve_gesture_click(&m.gestures, id));
if let Some(action) = action {
info!(button = %id, action = %action.label(), "gesture click → executing bound action");
dispatch_action(&action, &dpi_cycle, &capture);
}
}
return EventDisposition::Suppress;
}
return EventDisposition::Suppress;
}
}

// Single-action button.
let action = hooks.read().ok().and_then(|m| m.bindings.get(&id).cloned());
let Some(action) = action else {
// Unbound → leave the physical button to the OS.
return EventDisposition::PassThrough;
};
// Single-action button.
let action = hooks.read().ok().and_then(|m| m.bindings.get(&id).cloned());
let Some(action) = action else {
// Unbound → leave the physical button to the OS.
return EventDisposition::PassThrough;
};

// A button left on its own native click (e.g. Middle → MiddleClick)
// should just do that click; suppressing and re-synthesising it
// would be pointless churn.
if is_native_click(id, &action) {
return EventDisposition::PassThrough;
}
// A button left on its own native click (e.g. Middle → MiddleClick)
// should just do that click; suppressing and re-synthesising it
// would be pointless churn.
if is_native_click(id, &action) {
return EventDisposition::PassThrough;
}

if pressed {
info!(button = %id, action = %action.label(), "button → executing bound action");
dispatch_action(&action, &dpi_cycle, &capture);
if pressed {
info!(button = %id, action = %action.label(), "button → executing bound action");
dispatch_action(&action, &dpi_cycle, &capture);
}
EventDisposition::Suppress
}
EventDisposition::Suppress
}
MouseEvent::Moved { delta_x, delta_y } => {
// Feed an in-progress hold; a committed swipe fires here, mid-motion.
// Always pass through so the cursor keeps moving — the swipe is read,
// not consumed (the B2 cursor-drift tradeoff vs. a HID++ raw-XY divert
// that would freeze the pointer).
let commit = HOLD.with_borrow_mut(|h| h.accumulate(delta_x, delta_y));
if let Some((button, dir)) = commit {
// Resolve to an owned action and drop the read guard before
// dispatch (same lock-light rule as the release arm). The button
// can leave the gesture set mid-hold (a per-app rebuild); the
// commit has already armed `fired`, so the release won't fire a
// click. Fall back to the same click action the release path uses
// so the suppressed press is never swallowed into nothing —
// symmetric with `resolve_gesture_click`.
let action = hooks.read().ok().map(|m| {
m.gestures
.get(&button)
.and_then(|dirs| dirs.get(&dir).cloned())
.unwrap_or_else(|| resolve_gesture_click(&m.gestures, button))
});
if let Some(action) = action {
info!(button = %button, ?dir, action = %action.label(), "gesture swipe → executing bound action");
MouseEvent::Moved { delta_x, delta_y } => {
// Feed an in-progress hold; a committed swipe fires here, mid-motion.
// Always pass through so the cursor keeps moving — the swipe is read,
// not consumed (the B2 cursor-drift tradeoff vs. a HID++ raw-XY divert
// that would freeze the pointer).
let commit = HOLD.with_borrow_mut(|h| h.accumulate(delta_x, delta_y));
if let Some((button, dir)) = commit {
// Resolve to an owned action and drop the read guard before
// dispatch (same lock-light rule as the release arm). The button
// can leave the gesture set mid-hold (a per-app rebuild); the
// commit has already armed `fired`, so the release won't fire a
// click. Fall back to the same click action the release path uses
// so the suppressed press is never swallowed into nothing —
// symmetric with `resolve_gesture_click`.
let action = hooks.read().ok().map(|m| {
m.gestures
.get(&button)
.and_then(|dirs| dirs.get(&dir).cloned())
.unwrap_or_else(|| resolve_gesture_click(&m.gestures, button))
});
if let Some(action) = action {
info!(button = %button, ?dir, action = %action.label(), "gesture swipe → executing bound action");
dispatch_action(&action, &dpi_cycle, &capture);
}
}
EventDisposition::PassThrough
}
MouseEvent::CaptureInterrupted => {
// The OS dropped events (tap disabled); cancel any hold so a lost
// button-up can't later commit a phantom swipe off ordinary motion.
HOLD.with_borrow_mut(HoldState::cancel);
EventDisposition::PassThrough
}
MouseEvent::Scroll { .. } => EventDisposition::PassThrough,
},
// Function-key remapper: on key-down, look up a [keyboard.bindings]
// entry for this keycode + modifier mask. A match fires its action
// (suppressing the original key so it doesn't also type / trigger its
// native function); an unmatched key passes through untouched. Key-up
// is ignored to avoid double-firing the action.
HookEvent::Key(openlogi_hook::KeyEvent {
keycode,
pressed,
modifiers,
}) => {
if !pressed {
return EventDisposition::PassThrough;
}
let trigger = KeyTrigger {
keycode,
modifiers: convert_modifiers(modifiers),
};
match keyboard_bindings
.read()
.ok()
.and_then(|m| m.get(&trigger).cloned())
{
Some(action) => {
info!(keycode, action = %action.label(), "key → executing bound action");
dispatch_action(&action, &dpi_cycle, &capture);
EventDisposition::Suppress
}
None => EventDisposition::PassThrough,
}
EventDisposition::PassThrough
}
MouseEvent::CaptureInterrupted => {
// The OS dropped events (tap disabled); cancel any hold so a lost
// button-up can't later commit a phantom swipe off ordinary motion.
HOLD.with_borrow_mut(HoldState::cancel);
EventDisposition::PassThrough
}
MouseEvent::Scroll { .. } => EventDisposition::PassThrough,
});

match result {
Ok(hook) => {
info!("OS mouse hook installed");
info!("OS input hook installed");
Some(hook)
}
Err(e) => {
warn!(error = %e, "could not install OS mouse hook — events will not be captured");
warn!(error = %e, "could not install OS input hook — events will not be captured");
None
}
}
Expand Down
3 changes: 2 additions & 1 deletion crates/openlogi-agent-core/src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ use serde::{Deserialize, Serialize};
/// v6: `Capabilities::scroll_inversion` added.
/// v7: pairing commands return typed acceptance errors.
/// v8: [`WriteError`] carries typed HID++ operation failures.
pub const PROTOCOL_VERSION: u32 = 8;
/// v9: agent owns global keyboard bindings and function-key dispatch.
pub const PROTOCOL_VERSION: u32 = 9;

/// Where the agent's device enumeration stands. The distinction matters
/// because an empty inventory list is ambiguous on its own: the GUI must keep
Expand Down
13 changes: 13 additions & 0 deletions crates/openlogi-agent-core/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ pub struct SharedRuntime {
/// rebuild publishes both atomically (see [`HookMaps`]). Also read by the
/// gesture watcher for the thumb-wheel/DPI-button single actions.
pub hook_maps: SharedHookMaps,
/// Function-key remapper bindings (keycode+modifiers → action). Not
/// per-app-profile in M1 (spec non-goal), so a single shared map.
pub keyboard_bindings: crate::hook_runtime::SharedKeyboardBindings,
pub gesture_bindings: GestureBindings,
pub dpi_cycle: Arc<RwLock<DpiCycleState>>,
pub thumbwheel_sensitivity: Arc<AtomicI32>,
Expand Down Expand Up @@ -102,6 +105,7 @@ impl Orchestrator {
pub fn new(config: Config) -> Self {
let shared = SharedRuntime {
hook_maps: Arc::new(RwLock::new(HookMaps::default())),
keyboard_bindings: Arc::new(RwLock::new(config.keyboard.bindings.clone())),
gesture_bindings: Arc::new(RwLock::new(BTreeMap::new())),
dpi_cycle: Arc::new(RwLock::new(DpiCycleState::default())),
thumbwheel_sensitivity: Arc::new(AtomicI32::new(
Expand Down Expand Up @@ -175,6 +179,15 @@ impl Orchestrator {
},
"dpi_cycle",
);
// Keyboard F-key bindings are global (not per-device), so they key off
// the top-level config map rather than the selected device. Published
// here so `reload_config` (GUI commit) takes effect live, not only on
// agent restart.
write_value(
&self.shared.keyboard_bindings,
self.config.keyboard.bindings.clone(),
"keyboard_bindings",
);
self.shared.thumbwheel_sensitivity.store(
self.config.app_settings.thumbwheel_sensitivity,
Ordering::Relaxed,
Expand Down
7 changes: 4 additions & 3 deletions crates/openlogi-agent-core/src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ use tarpc::tokio_serde::formats::Bincode;
/// Resolve the IPC endpoint name.
///
/// On Unix this is the filesystem Unix-domain socket at
/// [`agent_socket_path`](openlogi_core::paths::agent_socket_path) (preserving
/// the existing `~/.config/openlogi/agent.sock` location, so macOS/Linux see no
/// behavior change). On Windows it is a named pipe in the OS namespace
/// [`agent_socket_path`](openlogi_core::paths::agent_socket_path). Production
/// builds keep `~/.config/openlogi/agent.sock`; local macOS `.dev` bundles use
/// the sibling `openlogi-dev` profile so development agents cannot occupy the
/// installed app's endpoint. On Windows it is a named pipe in the OS namespace
/// (`\\.\pipe\openlogi-agent.sock`).
///
/// # Errors
Expand Down
2 changes: 1 addition & 1 deletion crates/openlogi-agent-core/tests/wire_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ fn assert_wire<T: serde::Serialize>(value: &T, golden: &str) {
/// that makes that visible in the same diff.
#[test]
fn protocol_version_is_pinned() {
assert_eq!(PROTOCOL_VERSION, 8);
assert_eq!(PROTOCOL_VERSION, 9);
}

/// tarpc encodes the request enum's variant index, so trait *method order* is
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-agent/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ async fn run(config: Config) {
info!("accessibility granted — installing OS mouse hook");
hook = hook_runtime::start(
shared.hook_maps.clone(),
shared.keyboard_bindings.clone(),
shared.dpi_cycle.clone(),
shared.capture_channel.clone(),
);
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-agent/src/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ mod tests {
fn shared_runtime() -> SharedRuntime {
SharedRuntime {
hook_maps: Arc::new(RwLock::new(HookMaps::default())),
keyboard_bindings: Arc::new(RwLock::new(std::collections::HashMap::new())),
gesture_bindings: Arc::new(RwLock::new(BTreeMap::new())),
dpi_cycle: Arc::new(RwLock::new(DpiCycleState::default())),
thumbwheel_sensitivity: Arc::new(0.into()),
Expand Down
3 changes: 3 additions & 0 deletions crates/openlogi-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ tracing = { workspace = true }
fs4 = { version = "1.1.0", features = ["sync"] }
etcetera = "0.11.0"

[target.'cfg(target_os = "macos")'.dependencies]
plist = "1.9.0"

[dev-dependencies]
tempfile = "3"
tracing-subscriber = { workspace = true }
Expand Down
Loading