feat(desktop): portal-based Linux global shortcuts and PTT (#3185)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-04 01:08:51 +02:00
1 parent 5799ef705d
commit 8f58fcc4c4
149 files changed
+26849 -8769

No files matched your search

-20
View File
@@ -19,26 +19,6 @@ export type InputEvent =
metaKey: boolean;
x?: number;
y?: number;
}
| {
type: 'mousemove';
x: number;
y: number;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
}
| {
type: 'wheel';
x?: number;
y?: number;
deltaX: number;
deltaY: number;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
};
export declare class InputHook {
@@ -5,6 +5,15 @@ pub const CONTROL_MASK: u64 = 1 << 18;
pub const ALTERNATE_MASK: u64 = 1 << 19;
pub const COMMAND_MASK: u64 = 1 << 20;
const DEVICE_LEFT_CONTROL_MASK: u64 = 0x0000_0001;
const DEVICE_LEFT_SHIFT_MASK: u64 = 0x0000_0002;
const DEVICE_RIGHT_SHIFT_MASK: u64 = 0x0000_0004;
const DEVICE_LEFT_COMMAND_MASK: u64 = 0x0000_0008;
const DEVICE_RIGHT_COMMAND_MASK: u64 = 0x0000_0010;
const DEVICE_LEFT_ALTERNATE_MASK: u64 = 0x0000_0020;
const DEVICE_RIGHT_ALTERNATE_MASK: u64 = 0x0000_0040;
const DEVICE_RIGHT_CONTROL_MASK: u64 = 0x0000_2000;
const LEFT_SHIFT_KEYCODE: u16 = 0x38;
const RIGHT_SHIFT_KEYCODE: u16 = 0x3c;
const LEFT_CONTROL_KEYCODE: u16 = 0x3b;
@@ -31,14 +40,64 @@ pub fn from_flags(flags: u64) -> Modifiers {
}
}
pub fn modifier_key_down_from_flags(keycode: u16, flags: u64) -> Option<bool> {
match keycode {
LEFT_SHIFT_KEYCODE | RIGHT_SHIFT_KEYCODE => Some((flags & SHIFT_MASK) != 0),
LEFT_CONTROL_KEYCODE | RIGHT_CONTROL_KEYCODE => Some((flags & CONTROL_MASK) != 0),
LEFT_OPTION_KEYCODE | RIGHT_OPTION_KEYCODE => Some((flags & ALTERNATE_MASK) != 0),
LEFT_COMMAND_KEYCODE | RIGHT_COMMAND_KEYCODE => Some((flags & COMMAND_MASK) != 0),
_ => None,
struct ModifierSide {
group_mask: u64,
side_mask: u64,
other_side_mask: u64,
}
fn modifier_side(keycode: u16) -> Option<ModifierSide> {
let (group_mask, side_mask, other_side_mask) = match keycode {
LEFT_SHIFT_KEYCODE => (SHIFT_MASK, DEVICE_LEFT_SHIFT_MASK, DEVICE_RIGHT_SHIFT_MASK),
RIGHT_SHIFT_KEYCODE => (SHIFT_MASK, DEVICE_RIGHT_SHIFT_MASK, DEVICE_LEFT_SHIFT_MASK),
LEFT_CONTROL_KEYCODE => (
CONTROL_MASK,
DEVICE_LEFT_CONTROL_MASK,
DEVICE_RIGHT_CONTROL_MASK,
),
RIGHT_CONTROL_KEYCODE => (
CONTROL_MASK,
DEVICE_RIGHT_CONTROL_MASK,
DEVICE_LEFT_CONTROL_MASK,
),
LEFT_OPTION_KEYCODE => (
ALTERNATE_MASK,
DEVICE_LEFT_ALTERNATE_MASK,
DEVICE_RIGHT_ALTERNATE_MASK,
),
RIGHT_OPTION_KEYCODE => (
ALTERNATE_MASK,
DEVICE_RIGHT_ALTERNATE_MASK,
DEVICE_LEFT_ALTERNATE_MASK,
),
LEFT_COMMAND_KEYCODE => (
COMMAND_MASK,
DEVICE_LEFT_COMMAND_MASK,
DEVICE_RIGHT_COMMAND_MASK,
),
RIGHT_COMMAND_KEYCODE => (
COMMAND_MASK,
DEVICE_RIGHT_COMMAND_MASK,
DEVICE_LEFT_COMMAND_MASK,
),
_ => return None,
};
Some(ModifierSide {
group_mask,
side_mask,
other_side_mask,
})
}
pub fn modifier_key_down_from_flags(keycode: u16, flags: u64, was_held: bool) -> Option<bool> {
let side = modifier_side(keycode)?;
if flags & side.group_mask == 0 {
return Some(false);
}
if flags & (side.side_mask | side.other_side_mask) != 0 {
return Some(flags & side.side_mask != 0);
}
Some(!was_held)
}
#[cfg(test)]
@@ -85,19 +144,84 @@ mod tests {
}
#[test]
fn modifier_key_down_uses_matching_aggregate_flag() {
fn modifier_key_down_uses_the_device_side_bits() {
let both_shifts = SHIFT_MASK | DEVICE_LEFT_SHIFT_MASK | DEVICE_RIGHT_SHIFT_MASK;
assert_eq!(
modifier_key_down_from_flags(LEFT_SHIFT_KEYCODE, SHIFT_MASK),
modifier_key_down_from_flags(LEFT_SHIFT_KEYCODE, both_shifts, false),
Some(true)
);
let right_only = SHIFT_MASK | DEVICE_RIGHT_SHIFT_MASK;
assert_eq!(
modifier_key_down_from_flags(RIGHT_SHIFT_KEYCODE, 0),
modifier_key_down_from_flags(LEFT_SHIFT_KEYCODE, right_only, true),
Some(false)
);
assert_eq!(
modifier_key_down_from_flags(LEFT_COMMAND_KEYCODE, COMMAND_MASK),
modifier_key_down_from_flags(RIGHT_SHIFT_KEYCODE, right_only, false),
Some(true)
);
assert_eq!(modifier_key_down_from_flags(0x39, 0), None);
assert_eq!(
modifier_key_down_from_flags(RIGHT_SHIFT_KEYCODE, 0, true),
Some(false)
);
}
#[test]
fn releasing_one_side_while_the_other_is_held_is_a_keyup_for_every_group() {
let cases = [
(
LEFT_CONTROL_KEYCODE,
RIGHT_CONTROL_KEYCODE,
CONTROL_MASK,
DEVICE_LEFT_CONTROL_MASK,
DEVICE_RIGHT_CONTROL_MASK,
),
(
LEFT_OPTION_KEYCODE,
RIGHT_OPTION_KEYCODE,
ALTERNATE_MASK,
DEVICE_LEFT_ALTERNATE_MASK,
DEVICE_RIGHT_ALTERNATE_MASK,
),
(
LEFT_COMMAND_KEYCODE,
RIGHT_COMMAND_KEYCODE,
COMMAND_MASK,
DEVICE_LEFT_COMMAND_MASK,
DEVICE_RIGHT_COMMAND_MASK,
),
];
for (left, right, group, left_bit, right_bit) in cases {
assert_eq!(
modifier_key_down_from_flags(left, group | left_bit, false),
Some(true)
);
assert_eq!(
modifier_key_down_from_flags(right, group | left_bit | right_bit, false),
Some(true)
);
assert_eq!(
modifier_key_down_from_flags(left, group | right_bit, true),
Some(false)
);
assert_eq!(modifier_key_down_from_flags(right, 0, true), Some(false));
}
}
#[test]
fn missing_device_bits_fall_back_to_toggling_the_held_state() {
assert_eq!(
modifier_key_down_from_flags(RIGHT_OPTION_KEYCODE, ALTERNATE_MASK, false),
Some(true)
);
assert_eq!(
modifier_key_down_from_flags(RIGHT_OPTION_KEYCODE, ALTERNATE_MASK, true),
Some(false)
);
}
#[test]
fn non_modifier_keycodes_are_not_decided() {
assert_eq!(modifier_key_down_from_flags(0x39, 0, false), None);
assert_eq!(modifier_key_down_from_flags(0x00, SHIFT_MASK, true), None);
}
}
@@ -7,16 +7,11 @@ pub enum CgEventType {
LeftMouseUp = 2,
RightMouseDown = 3,
RightMouseUp = 4,
MouseMoved = 5,
LeftMouseDragged = 6,
RightMouseDragged = 7,
KeyDown = 10,
KeyUp = 11,
FlagsChanged = 12,
ScrollWheel = 22,
OtherMouseDown = 25,
OtherMouseUp = 26,
OtherMouseDragged = 27,
}
impl CgEventType {
@@ -26,16 +21,11 @@ impl CgEventType {
2 => Self::LeftMouseUp,
3 => Self::RightMouseDown,
4 => Self::RightMouseUp,
5 => Self::MouseMoved,
6 => Self::LeftMouseDragged,
7 => Self::RightMouseDragged,
10 => Self::KeyDown,
11 => Self::KeyUp,
12 => Self::FlagsChanged,
22 => Self::ScrollWheel,
25 => Self::OtherMouseDown,
26 => Self::OtherMouseUp,
27 => Self::OtherMouseDragged,
_ => return None,
})
}
@@ -117,13 +107,15 @@ mod tests {
assert!(is_down(CgEventType::LeftMouseDown));
assert!(!is_down(CgEventType::LeftMouseUp));
assert!(is_down(CgEventType::OtherMouseDown));
assert!(!is_down(CgEventType::MouseMoved));
assert!(!is_down(CgEventType::KeyDown));
}
#[test]
fn from_u32_maps_known_event_types() {
assert_eq!(Some(CgEventType::LeftMouseDown), CgEventType::from_u32(1));
assert_eq!(Some(CgEventType::ScrollWheel), CgEventType::from_u32(22));
assert_eq!(Some(CgEventType::OtherMouseUp), CgEventType::from_u32(26));
assert_eq!(None, CgEventType::from_u32(5));
assert_eq!(None, CgEventType::from_u32(22));
assert_eq!(None, CgEventType::from_u32(999));
}
}
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::BTreeSet;
use std::ffi::c_void;
use std::ptr;
use std::sync::{
Arc, Mutex,
Arc, Mutex, MutexGuard,
atomic::{AtomicBool, AtomicPtr, AtomicU64, Ordering},
mpsc::{Receiver, Sender, channel},
};
@@ -33,14 +34,12 @@ unsafe extern "C" {
fn CGEventTapEnable(tap: CFMachPortRef, enable: bool);
}
#[derive(Debug)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InputEventKind {
KeyDown,
KeyUp,
MouseDown,
MouseUp,
MouseMove,
Wheel,
}
#[derive(Debug)]
@@ -50,8 +49,6 @@ pub struct InputEventPayload {
keycode: u32,
key_name: String,
button: u8,
delta_x: i32,
delta_y: i32,
x: i32,
y: i32,
has_xy: bool,
@@ -66,8 +63,6 @@ impl ToNapiValue for InputEventPayload {
InputEventKind::KeyUp => "keyup",
InputEventKind::MouseDown => "mousedown",
InputEventKind::MouseUp => "mouseup",
InputEventKind::MouseMove => "mousemove",
InputEventKind::Wheel => "wheel",
};
obj.set("type", kind_str)?;
obj.set("ctrlKey", value.mods.ctrl)?;
@@ -87,18 +82,6 @@ impl ToNapiValue for InputEventPayload {
obj.set("y", value.y)?;
}
}
InputEventKind::MouseMove => {
obj.set("x", value.x)?;
obj.set("y", value.y)?;
}
InputEventKind::Wheel => {
obj.set("deltaX", value.delta_x)?;
obj.set("deltaY", value.delta_y)?;
if value.has_xy {
obj.set("x", value.x)?;
obj.set("y", value.y)?;
}
}
}
unsafe { <Object<'_> as ToNapiValue>::to_napi_value(raw_env, obj) }
}
@@ -112,12 +95,9 @@ type EventTsfn = Arc<
Status,
false,
true,
EVENT_QUEUE_LIMIT,
>,
>;
const EVENT_QUEUE_LIMIT: usize = 1024;
struct WorkerState {
run_loop: CFRunLoop,
join: JoinHandle<()>,
@@ -138,7 +118,6 @@ impl InputHook {
.build_threadsafe_function::<InputEventPayload>()
.weak::<true>()
.callee_handled::<false>()
.max_queue_size::<EVENT_QUEUE_LIMIT>()
.build()
.map_err(|err| {
Error::new(
@@ -235,11 +214,6 @@ const EVENTS_OF_INTEREST: &[CGEventType] = &[
CGEventType::RightMouseUp,
CGEventType::OtherMouseDown,
CGEventType::OtherMouseUp,
CGEventType::MouseMoved,
CGEventType::LeftMouseDragged,
CGEventType::RightMouseDragged,
CGEventType::OtherMouseDragged,
CGEventType::ScrollWheel,
];
const CAPS_LOCK_KEYCODE: u16 = 0x39;
@@ -253,6 +227,7 @@ fn worker_main(tsfn: EventTsfn, loop_tx: Sender<std::result::Result<CFRunLoop, S
tap_ref: tap_ref.clone(),
last_flags: last_flags.clone(),
caps_lock_via_hid: caps_lock_via_hid.clone(),
held: Mutex::new(HeldInputs::default()),
};
let tap_result = CGEventTap::new(
CGEventTapLocation::Session,
@@ -319,8 +294,67 @@ fn caps_lock_payload(pressed: bool, flags: u64) -> InputEventPayload {
keycode: u32::from(CAPS_LOCK_KEYCODE),
key_name: keymap::keycode_name_or_fallback(CAPS_LOCK_KEYCODE),
button: 0,
delta_x: 0,
delta_y: 0,
x: 0,
y: 0,
has_xy: false,
}
}
#[derive(Default)]
struct HeldInputs {
keys: BTreeSet<u16>,
buttons: BTreeSet<u8>,
}
impl HeldInputs {
fn track(&mut self, payload: &InputEventPayload) {
#[allow(clippy::cast_possible_truncation)]
let keycode = payload.keycode as u16;
match payload.kind {
InputEventKind::KeyDown => {
self.keys.insert(keycode);
}
InputEventKind::KeyUp => {
self.keys.remove(&keycode);
}
InputEventKind::MouseDown => {
self.buttons.insert(payload.button);
}
InputEventKind::MouseUp => {
self.buttons.remove(&payload.button);
}
}
}
fn take_releases(&mut self) -> Vec<InputEventPayload> {
let mods = modifiers::from_flags(0);
let keys = std::mem::take(&mut self.keys)
.into_iter()
.map(|keycode| key_payload(InputEventKind::KeyUp, keycode, mods));
let buttons =
std::mem::take(&mut self.buttons)
.into_iter()
.map(|button| InputEventPayload {
kind: InputEventKind::MouseUp,
mods,
keycode: 0,
key_name: String::new(),
button,
x: 0,
y: 0,
has_xy: false,
});
keys.chain(buttons).collect()
}
}
fn key_payload(kind: InputEventKind, keycode: u16, mods: Modifiers) -> InputEventPayload {
InputEventPayload {
kind,
mods,
keycode: u32::from(keycode),
key_name: keymap::keycode_name_or_fallback(keycode),
button: 0,
x: 0,
y: 0,
has_xy: false,
@@ -332,6 +366,7 @@ struct EventDispatcher {
tap_ref: Arc<AtomicPtr<c_void>>,
last_flags: Arc<AtomicU64>,
caps_lock_via_hid: Arc<AtomicBool>,
held: Mutex<HeldInputs>,
}
impl EventDispatcher {
@@ -344,6 +379,7 @@ impl EventDispatcher {
match event_type {
CGEventType::TapDisabledByTimeout | CGEventType::TapDisabledByUserInput => {
self.reenable_tap();
self.release_all();
return CallbackResult::Keep;
}
_ => {}
@@ -380,7 +416,9 @@ impl EventDispatcher {
self.send_key(InputEventKind::KeyUp, keycode, mods);
return CallbackResult::Keep;
}
let Some(is_down) = modifiers::modifier_key_down_from_flags(keycode, flags.bits())
let was_held = self.held().keys.contains(&keycode);
let Some(is_down) =
modifiers::modifier_key_down_from_flags(keycode, flags.bits(), was_held)
else {
return CallbackResult::Keep;
};
@@ -414,8 +452,6 @@ impl EventDispatcher {
keycode: 0,
key_name: String::new(),
button: b,
delta_x: 0,
delta_y: 0,
x: point.x as i32,
y: point.y as i32,
has_xy: true,
@@ -423,74 +459,37 @@ impl EventDispatcher {
self.send(payload);
}
}
CgEventType::MouseMoved
| CgEventType::LeftMouseDragged
| CgEventType::RightMouseDragged
| CgEventType::OtherMouseDragged => {
let point = event.location();
#[allow(clippy::cast_possible_truncation)]
let payload = InputEventPayload {
kind: InputEventKind::MouseMove,
mods,
keycode: 0,
key_name: String::new(),
button: 0,
delta_x: 0,
delta_y: 0,
x: point.x as i32,
y: point.y as i32,
has_xy: true,
};
self.send(payload);
}
CgEventType::ScrollWheel => {
let dy = event.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_1)
as i32;
let dx = event.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_2)
as i32;
let point = event.location();
#[allow(clippy::cast_possible_truncation)]
let payload = InputEventPayload {
kind: InputEventKind::Wheel,
mods,
keycode: 0,
key_name: String::new(),
button: 0,
delta_x: dx,
delta_y: dy,
x: point.x as i32,
y: point.y as i32,
has_xy: true,
};
self.send(payload);
}
}
CallbackResult::Keep
}
fn send_key(&self, kind: InputEventKind, keycode: u16, mods: Modifiers) {
let payload = InputEventPayload {
kind,
mods,
keycode: u32::from(keycode),
key_name: keymap::keycode_name_or_fallback(keycode),
button: 0,
delta_x: 0,
delta_y: 0,
x: 0,
y: 0,
has_xy: false,
};
self.send(payload);
self.send(key_payload(kind, keycode, mods));
}
fn held(&self) -> MutexGuard<'_, HeldInputs> {
self.held
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn send(&self, payload: InputEventPayload) {
self.held().track(&payload);
let _ = self
.tsfn
.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
}
fn release_all(&self) {
let releases = self.held().take_releases();
for payload in releases {
let _ = self
.tsfn
.call(payload, ThreadsafeFunctionCallMode::NonBlocking);
}
}
fn reenable_tap(&self) {
let tap: CFMachPortRef = self.tap_ref.load(Ordering::Acquire).cast();
if !tap.is_null() {
@@ -502,3 +501,43 @@ impl EventDispatcher {
pub fn has_accessibility_permission() -> bool {
objc2_core_graphics::CGPreflightListenEventAccess()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn held_inputs_release_every_pressed_key_and_button_once() {
let mods = modifiers::from_flags(0);
let mut held = HeldInputs::default();
held.track(&key_payload(InputEventKind::KeyDown, 0x31, mods));
held.track(&key_payload(InputEventKind::KeyDown, 0x3b, mods));
held.track(&key_payload(InputEventKind::KeyDown, 0x00, mods));
held.track(&key_payload(InputEventKind::KeyUp, 0x00, mods));
held.track(&InputEventPayload {
kind: InputEventKind::MouseDown,
mods,
keycode: 0,
key_name: String::new(),
button: 3,
x: 0,
y: 0,
has_xy: true,
});
let releases = held.take_releases();
let summary: Vec<(InputEventKind, u32, u8)> = releases
.iter()
.map(|payload| (payload.kind, payload.keycode, payload.button))
.collect();
assert_eq!(
summary,
vec![
(InputEventKind::KeyUp, 0x31, 0),
(InputEventKind::KeyUp, 0x3b, 0),
(InputEventKind::MouseUp, 0, 3),
]
);
assert!(releases.iter().all(|payload| !payload.mods.ctrl));
assert!(held.take_releases().is_empty());
}
}