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 .claude/rules/objc-ffi.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ paths:
- "crates/openlogi-agent-core/src/watchers/camera.rs"
- "crates/openlogi-hook/src/macos.rs"
- "crates/openlogi-inject/src/inject/macos.rs"
- "crates/openlogi-gamepad/src/macos.rs"
- "crates/openlogi-hid/src/permissions.rs"
---

Expand All @@ -33,6 +34,7 @@ files; **keep this table in sync when you add or move one**:
| `openlogi-hid/src/permissions.rs` | `IOHIDCheckAccess` / `IOHIDRequestAccess` (the prompting half of Input Monitoring) |
| `openlogi-hook/src/macos.rs` | the CGEventTap (on `core-graphics`, see below), the off-tap `NSWorkspace` frontmost-app read and Safari PID snapshot, the Accessibility-trust check/prompt, and the HID sender-id lookup |
| `openlogi-inject/src/inject/macos.rs` | CGEvent synthesis, media-key `NSEvent`s, off-thread `NSWorkspace` validation, typed `AXUIElement` navigation with `CFRetained` ownership, and the `dlopen`'d private SPIs |
| `openlogi-gamepad/src/macos.rs` | `IOHIDUserDevice` virtual gamepad create/emit/output-rumble callback (raw IOKit C API) |
| `openlogi-overlay/src/platform.rs` | the Actions Ring helper's window policy: accessory activation, non-activating panel, the `NSEvent` global click-away monitor (`block2`), and `CGGetActiveDisplayList` / `CGDisplayBounds` |
| `openlogi-permissions/src/macos.rs` | non-prompting permission reads + System-Settings deep links; `+[CBManager authorization]` via an `AnyClass` lookup |

Expand Down
12 changes: 12 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 Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"crates/openlogi-device-registry",
"crates/openlogi-fixture",
"crates/openlogi-inject",
"crates/openlogi-gamepad",
"crates/openlogi-hidpp",
"crates/openlogi-hidpp-derive",
"crates/openlogi-hid",
Expand Down
1 change: 1 addition & 0 deletions crates/openlogi-agent-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ publish = false
[dependencies]
openlogi-core = { path = "../openlogi-core" }
openlogi-inject = { path = "../openlogi-inject" }
openlogi-gamepad = { path = "../openlogi-gamepad" }
openlogi-hid = { path = "../openlogi-hid" }
openlogi-hook = { path = "../openlogi-hook" }
openlogi-ipc = { path = "../openlogi-ipc" }
Expand Down
138 changes: 125 additions & 13 deletions crates/openlogi-agent-core/src/capture_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
use std::collections::BTreeMap;
use std::sync::Arc;

use openlogi_core::binding::{Action, Binding, ButtonId, GestureDirection, default_binding};
use openlogi_core::binding::{
Action, Binding, ButtonId, GamepadMap, GestureDirection, default_binding,
};
use openlogi_core::bindings::{button_bindings_for, hidpp_gesture_maps_for, oshook_gestures_for};
use openlogi_core::config::{Config, ThumbwheelSensitivity};
use openlogi_core::device_order::PhysicalDeviceKey;
Expand Down Expand Up @@ -64,6 +66,9 @@ pub struct DispatchPlan {
/// This device's effective thumb-wheel sensitivity (device override or the
/// app-wide default).
pub thumbwheel_sensitivity: ThumbwheelSensitivity,
/// When set, mapped controls feed the auxiliary virtual gamepad instead of
/// productivity actions.
pub gamepad: Option<GamepadMap>,
}

/// One device's independently versioned hardware target and dispatch plan.
Expand Down Expand Up @@ -95,6 +100,17 @@ pub(crate) fn hidpp_side_gesture_maps_for(
.collect()
}

/// Host/runtime facts that are not config but still shape diversion.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CapturePlanRuntime {
/// Whether the OS mouse hook can own side-button gesture presses.
pub os_mouse_hook_available: bool,
/// Whether [`crate::GamepadPads`] holds a created pad for this device —
/// diversion stays off when create failed so mapped controls keep
/// productivity / native behaviour (fail-open).
pub gamepad_live: bool,
}

/// Build one device's plan from the config (per-app effective for `app`).
#[must_use]
pub fn plan_for_device(
Expand All @@ -104,7 +120,7 @@ pub fn plan_for_device(
route: DeviceRoute,
app: Option<&str>,
rearm_generation: u64,
os_mouse_hook_available: bool,
runtime: CapturePlanRuntime,
) -> DeviceCapturePlan {
let bindings = button_bindings_for(config, Some(config_key), app);
// Gesture-mode OS-hook controls normally stay native so the hook sees the
Expand All @@ -117,8 +133,10 @@ pub fn plan_for_device(
// gesture at once, each armed with its own raw-XY divert (the capture
// target below derives the CIDs to divert from this map's keys).
let gesture_bindings = hidpp_gesture_maps_for(config, Some(config_key), app);
let gamepad_map = (config.gamepad(config_key).enabled && runtime.gamepad_live)
.then(GamepadMap::default_for_mouse);
let mut divert_gesture_buttons = Vec::new();
if os_mouse_hook_available {
if runtime.os_mouse_hook_available {
divert_gesture_buttons.extend(
DIVERTABLE_STANDARD_BUTTONS
.into_iter()
Expand All @@ -139,7 +157,7 @@ pub fn plan_for_device(
let plain_sources = GESTURE_SOURCE_BUTTONS
.into_iter()
.filter(|(_, button)| !gesture_bindings.contains_key(button));
let divert_buttons: Vec<(u16, ButtonId)> = DIVERTABLE_STANDARD_BUTTONS
let mut divert_buttons: Vec<(u16, ButtonId)> = DIVERTABLE_STANDARD_BUTTONS
.into_iter()
.chain(plain_sources)
// These controls are owned by the OS-hook path. The capture opt-out
Expand All @@ -150,6 +168,12 @@ pub fn plan_for_device(
})
.filter(|(_, button)| !oshook.contains_key(button))
.filter(|(_, button)| {
if gamepad_map
.as_ref()
.is_some_and(|map| map.owns_button(*button))
{
return true;
}
bindings.get(button).is_some_and(|binding| {
if matches!(binding, Binding::LongPress(_)) {
return true;
Expand All @@ -173,23 +197,32 @@ pub fn plan_for_device(
]
.iter()
.any(|button| {
bindings
.get(button)
.is_some_and(|binding| binding.click_action() != default_binding(*button))
gamepad_map
.as_ref()
.is_some_and(|map| map.owns_button(*button))
|| bindings
.get(button)
.is_some_and(|binding| binding.click_action() != default_binding(*button))
});
let thumbwheel_sensitivity = config.thumbwheel_sensitivity(config_key);
let mut divert_gesture_sources: Vec<u16> = GESTURE_SOURCE_BUTTONS
.into_iter()
.filter(|(_, button)| gesture_bindings.contains_key(button))
.map(|(cid, _)| cid)
.collect();
merge_gamepad_diverts(
gamepad_map.as_ref(),
&mut divert_buttons,
&mut divert_gesture_sources,
);
DeviceCapturePlan {
target: CaptureTarget {
physical_key,
route,
spec: CaptureSpec {
capture_thumbwheel: thumbwheel_sensitivity != ThumbwheelSensitivity::DEFAULT
|| thumbwheel_bindings_nondefault,
divert_gesture_sources: GESTURE_SOURCE_BUTTONS
.into_iter()
.filter(|(_, button)| gesture_bindings.contains_key(button))
.map(|(cid, _)| cid)
.collect(),
divert_gesture_sources,
divert_gesture_buttons,
divert_buttons,
},
Expand All @@ -201,10 +234,34 @@ pub fn plan_for_device(
gesture_bindings,
side_gesture_bindings,
thumbwheel_sensitivity,
gamepad: gamepad_map,
},
}
}

/// Force-divert gamepad-owned controls that the productivity path left native.
fn merge_gamepad_diverts(
gamepad_map: Option<&GamepadMap>,
divert_buttons: &mut Vec<(u16, ButtonId)>,
divert_gesture_sources: &mut Vec<u16>,
) {
let Some(map) = gamepad_map else {
return;
};
// Gamepad-owned OS-hook buttons (Back/Forward) must be HID++-diverted even
// when the hook is up, otherwise they stay native mouse buttons.
for (cid, button) in DIVERTABLE_STANDARD_BUTTONS {
if map.owns_button(button) && !divert_buttons.iter().any(|(_, b)| *b == button) {
divert_buttons.push((cid, button));
}
}
for (cid, button) in GESTURE_SOURCE_BUTTONS {
if map.owns_button(button) && !divert_gesture_sources.contains(&cid) {
divert_gesture_sources.push(cid);
}
}
}

#[cfg(test)]
mod tests {
use openlogi_core::binding::{Binding, LongPressBinding};
Expand Down Expand Up @@ -235,7 +292,10 @@ mod tests {
route,
app,
rearm_generation,
os_mouse_hook_available,
CapturePlanRuntime {
os_mouse_hook_available,
gamepad_live: false,
},
)
}

Expand Down Expand Up @@ -783,4 +843,56 @@ mod tests {
assert!(plan.dispatch.side_gesture_bindings.is_empty());
}
}

#[test]
fn gamepad_map_requires_a_live_pad() {
// Enabled-but-failed create must fail open: no map, no gamepad divert.
let mut cfg = Config::default();
cfg.devices
.entry("2b042".into())
.or_default()
.gamepad
.enabled = true;

let dead = plan_for_device(&cfg, "2b042", route(), None, 0, true);
Comment thread
humbertogontijo marked this conversation as resolved.
assert!(
dead.dispatch.gamepad.is_none(),
"without a live pad the map must stay off"
);
assert!(
!dead
.target
.spec
.divert_buttons
.iter()
.any(|&(_, button)| button == ButtonId::Back),
"Back must keep native/productivity behaviour when create failed"
);

let live = super::plan_for_device(
&cfg,
PhysicalDeviceKey::parse("receiver:cafe:slot:2")
.expect("fixture should be a physical key"),
"2b042",
route(),
None,
0,
CapturePlanRuntime {
os_mouse_hook_available: true,
gamepad_live: true,
},
);
assert!(
live.dispatch.gamepad.is_some(),
"a live pad publishes the default mouse map"
);
assert!(
live.target
.spec
.divert_buttons
.iter()
.any(|&(_, button)| button == ButtonId::Back),
"Back is on the default map and must divert when the pad is live"
);
}
}
Loading