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

+2 -21
View File
@@ -4,6 +4,7 @@ export type InputEvent =
| {
type: 'keydown' | 'keyup';
keycode: number;
x11Keycode: number;
keyName: string;
ctrlKey: boolean;
altKey: boolean;
@@ -19,32 +20,12 @@ 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 {
constructor(callback: (event: InputEvent) => void);
start(): void;
start(): Promise<void>;
stop(): void;
}
@@ -1,5 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplayServer {
X11,
@@ -18,31 +21,45 @@ impl DisplayServer {
}
pub fn detect_display_server() -> DisplayServer {
let wayland_socket = wayland_socket_path(
std::env::var_os("WAYLAND_DISPLAY").as_deref(),
std::env::var_os("XDG_RUNTIME_DIR").as_deref(),
);
detect_from(
std::env::var("XDG_SESSION_TYPE").ok().as_deref(),
std::env::var("DISPLAY").ok().as_deref(),
std::env::var("WAYLAND_DISPLAY").ok().as_deref(),
wayland_socket.is_some_and(|path| path.exists()),
)
}
fn wayland_socket_path(
wayland_display: Option<&OsStr>,
xdg_runtime_dir: Option<&OsStr>,
) -> Option<PathBuf> {
let display = Path::new(wayland_display.filter(|value| !value.is_empty())?);
if display.is_absolute() {
return Some(display.to_path_buf());
}
let runtime_dir = xdg_runtime_dir.filter(|value| !value.is_empty())?;
Some(Path::new(runtime_dir).join(display))
}
fn detect_from(
xdg_session_type: Option<&str>,
display: Option<&str>,
wayland_display: Option<&str>,
wayland_socket_exists: bool,
) -> DisplayServer {
let has_x11 = display.is_some_and(|v| !v.is_empty());
let has_wayland = wayland_display.is_some_and(|v| !v.is_empty());
let xdg_is_wayland = xdg_session_type == Some("wayland");
match (has_x11, has_wayland) {
let is_wayland = match xdg_session_type {
Some("x11") => return DisplayServer::X11,
Some("wayland") => true,
_ => wayland_socket_exists,
};
match (is_wayland, has_x11) {
(true, true) => DisplayServer::WaylandWithXwayland,
(true, false) if xdg_is_wayland => DisplayServer::WaylandWithXwayland,
(true, false) => DisplayServer::X11,
(false, true) => DisplayServer::Wayland,
(false, false) => match xdg_session_type {
Some("x11") => DisplayServer::X11,
Some("wayland") => DisplayServer::Wayland,
_ => DisplayServer::Unknown,
},
(true, false) => DisplayServer::Wayland,
(false, true) => DisplayServer::X11,
(false, false) => DisplayServer::Unknown,
}
}
@@ -53,61 +70,101 @@ mod tests {
#[test]
fn detect_pure_x11_from_display_only() {
assert_eq!(
detect_from(Some("x11"), Some(":0"), None),
detect_from(Some("x11"), Some(":0"), false),
DisplayServer::X11
);
assert_eq!(detect_from(None, Some(":0"), None), DisplayServer::X11);
assert_eq!(detect_from(None, Some(":0"), false), DisplayServer::X11);
}
#[test]
fn detect_pure_wayland_from_wayland_display_only() {
fn x11_session_type_wins_over_leaked_wayland_display() {
assert_eq!(
detect_from(Some("wayland"), None, Some("wayland-0")),
DisplayServer::Wayland
);
assert_eq!(
detect_from(None, None, Some("wayland-0")),
DisplayServer::Wayland
detect_from(Some("x11"), Some(":0"), true),
DisplayServer::X11
);
}
#[test]
fn detect_xwayland_when_both_sockets_present() {
let ds = detect_from(Some("wayland"), Some(":0"), Some("wayland-0"));
fn detect_pure_wayland_from_socket_only() {
assert_eq!(
detect_from(Some("wayland"), None, true),
DisplayServer::Wayland
);
assert_eq!(detect_from(None, None, true), DisplayServer::Wayland);
}
#[test]
fn detect_xwayland_when_socket_and_display_present() {
let ds = detect_from(None, Some(":0"), true);
assert_eq!(ds, DisplayServer::WaylandWithXwayland);
assert!(!ds.supports_global_xrecord());
}
#[test]
fn detect_xwayland_from_wayland_session_with_display_only() {
let ds = detect_from(Some("wayland"), Some(":0"), None);
let ds = detect_from(Some("wayland"), Some(":0"), false);
assert_eq!(ds, DisplayServer::WaylandWithXwayland);
assert!(!ds.supports_global_xrecord());
}
#[test]
fn missing_wayland_socket_without_session_type_is_x11() {
assert_eq!(
detect_from(Some("tty"), Some(":0"), false),
DisplayServer::X11
);
}
#[test]
fn detect_unknown_when_nothing_set() {
assert_eq!(detect_from(None, None, None), DisplayServer::Unknown);
assert_eq!(detect_from(Some("tty"), None, None), DisplayServer::Unknown);
assert_eq!(detect_from(None, None, false), DisplayServer::Unknown);
assert_eq!(
detect_from(Some("tty"), None, false),
DisplayServer::Unknown
);
}
#[test]
fn detect_empty_display_var_is_ignored() {
assert_eq!(
detect_from(Some("wayland"), Some(""), Some("wayland-0")),
detect_from(Some("wayland"), Some(""), true),
DisplayServer::Wayland
);
}
#[test]
fn xdg_session_type_fallback_when_sockets_missing() {
assert_eq!(detect_from(Some("x11"), None, None), DisplayServer::X11);
fn xdg_session_type_without_sockets() {
assert_eq!(detect_from(Some("x11"), None, false), DisplayServer::X11);
assert_eq!(
detect_from(Some("wayland"), None, None),
detect_from(Some("wayland"), None, false),
DisplayServer::Wayland
);
}
#[test]
fn wayland_socket_path_resolves_relative_names_under_runtime_dir() {
assert_eq!(
wayland_socket_path(
Some(OsStr::new("wayland-0")),
Some(OsStr::new("/run/user/1000"))
),
Some(PathBuf::from("/run/user/1000/wayland-0"))
);
assert_eq!(
wayland_socket_path(Some(OsStr::new("/tmp/wl")), None),
Some(PathBuf::from("/tmp/wl"))
);
assert_eq!(
wayland_socket_path(Some(OsStr::new("wayland-0")), None),
None
);
assert_eq!(
wayland_socket_path(Some(OsStr::new("")), Some(OsStr::new("/run"))),
None
);
assert_eq!(wayland_socket_path(None, Some(OsStr::new("/run"))), None);
}
#[test]
fn global_xrecord_support_is_only_for_pure_x11() {
assert!(DisplayServer::X11.supports_global_xrecord());
+370 -211
View File
@@ -2,34 +2,37 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::sync::atomic::{AtomicBool, Ordering};
use std::collections::BTreeSet;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, SyncSender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use napi::{
Env, Status,
bindgen_prelude::{Function, Object, Result, ToNapiValue},
Env, Status, Task,
bindgen_prelude::{AsyncTask, Function, Object, Result, ToNapiValue},
sys,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode, UnknownReturnValue},
};
use napi_derive::napi;
use x11rb::connection::{Connection, RequestConnection};
use x11rb::errors::ReplyError;
use x11rb::protocol::Event;
use x11rb::protocol::record::{
self, ConnectionExt as RecordConnectionExt, ExtRange, Range, Range8, Range16,
};
use x11rb::protocol::xproto::{
ConnectionExt as XprotoConnectionExt, GetKeyboardMappingReply, Keycode,
ConnectionExt as XprotoConnectionExt, GetKeyboardMappingReply, Keycode, Mapping,
};
use x11rb::rust_connection::RustConnection;
use x11rb::wrapper::ConnectionExt as WrapperConnectionExt;
use x11rb::x11_utils::TryParse;
use crate::env::{DisplayServer, detect_display_server};
use crate::env::detect_display_server;
use crate::keymap;
use crate::modifiers::{self, Modifiers};
use crate::mouse::{self, MouseClassification};
use crate::x11;
use crate::mouse;
const RECORD_FROM_SERVER: u8 = 0;
const RECORD_START_OF_DATA: u8 = 4;
@@ -38,7 +41,7 @@ const KEY_PRESS: u8 = 2;
const KEY_RELEASE: u8 = 3;
const BUTTON_PRESS: u8 = 4;
const BUTTON_RELEASE: u8 = 5;
const MOTION_NOTIFY: u8 = 6;
const START_TIMEOUT: Duration = Duration::from_secs(2);
#[repr(C)]
#[derive(Debug, Clone, Copy)]
@@ -66,18 +69,26 @@ pub enum EventKind {
KeyUp,
MouseDown,
MouseUp,
MouseMove,
Wheel,
}
impl EventKind {
fn as_str(&self) -> &'static str {
match self {
Self::KeyDown => "keydown",
Self::KeyUp => "keyup",
Self::MouseDown => "mousedown",
Self::MouseUp => "mouseup",
}
}
}
#[derive(Debug, Clone)]
pub struct DecodedEvent {
pub kind: EventKind,
pub keycode: u32,
pub x11_keycode: u8,
pub key_name: String,
pub button: u8,
pub delta_x: i32,
pub delta_y: i32,
pub x: i32,
pub y: i32,
pub has_xy: bool,
@@ -89,10 +100,9 @@ impl DecodedEvent {
Self {
kind,
keycode: 0,
x11_keycode: 0,
key_name: String::new(),
button: 0,
delta_x: 0,
delta_y: 0,
x: 0,
y: 0,
has_xy: false,
@@ -105,15 +115,7 @@ impl ToNapiValue for DecodedEvent {
unsafe fn to_napi_value(raw_env: sys::napi_env, event: Self) -> Result<sys::napi_value> {
let env = Env::from_raw(raw_env);
let mut object = Object::new(&env)?;
let kind = match event.kind {
EventKind::KeyDown => "keydown",
EventKind::KeyUp => "keyup",
EventKind::MouseDown => "mousedown",
EventKind::MouseUp => "mouseup",
EventKind::MouseMove => "mousemove",
EventKind::Wheel => "wheel",
};
object.set("type", kind)?;
object.set("type", event.kind.as_str())?;
object.set("ctrlKey", event.mods.ctrl)?;
object.set("altKey", event.mods.alt)?;
object.set("shiftKey", event.mods.shift)?;
@@ -121,6 +123,7 @@ impl ToNapiValue for DecodedEvent {
match event.kind {
EventKind::KeyDown | EventKind::KeyUp => {
object.set("keycode", event.keycode)?;
object.set("x11Keycode", u32::from(event.x11_keycode))?;
object.set("keyName", event.key_name.as_str())?;
}
EventKind::MouseDown | EventKind::MouseUp => {
@@ -130,36 +133,13 @@ impl ToNapiValue for DecodedEvent {
object.set("y", event.y)?;
}
}
EventKind::MouseMove => {
object.set("x", event.x)?;
object.set("y", event.y)?;
}
EventKind::Wheel => {
object.set("deltaX", event.delta_x)?;
object.set("deltaY", event.delta_y)?;
if event.has_xy {
object.set("x", event.x)?;
object.set("y", event.y)?;
}
}
}
unsafe { <Object<'_> as ToNapiValue>::to_napi_value(raw_env, object) }
}
}
const EVENT_QUEUE_LIMIT: usize = 1024;
type EventTsfn = Arc<
ThreadsafeFunction<
DecodedEvent,
UnknownReturnValue,
DecodedEvent,
Status,
false,
true,
EVENT_QUEUE_LIMIT,
>,
>;
type EventTsfn =
Arc<ThreadsafeFunction<DecodedEvent, UnknownReturnValue, DecodedEvent, Status, false, true>>;
#[derive(Clone)]
struct KeysymCache {
@@ -180,6 +160,21 @@ impl KeysymCache {
Self { min_keycode, syms }
}
fn fetch(conn: &RustConnection) -> std::result::Result<Self, String> {
let setup = conn.setup();
let min_keycode = setup.min_keycode;
let count = setup
.max_keycode
.saturating_sub(min_keycode)
.saturating_add(1);
let mapping = conn
.get_keyboard_mapping(min_keycode, count)
.map_err(|err| format!("GetKeyboardMapping: {err}"))?
.reply()
.map_err(|err| format!("GetKeyboardMapping: {err}"))?;
Ok(Self::build(&mapping, min_keycode))
}
fn lookup(&self, keycode: u8) -> u32 {
if keycode < self.min_keycode {
return 0;
@@ -199,6 +194,78 @@ struct Active {
struct Inner {
callback: EventTsfn,
active: Mutex<Option<Active>>,
generation: AtomicU64,
}
impl Inner {
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Option<Active>>> {
self.active
.lock()
.map_err(|_| generic_error("InputHook lock poisoned"))
}
fn start(&self, generation: u64) -> Result<()> {
let dead = {
let mut guard = self.lock()?;
if guard
.as_ref()
.is_some_and(|active| !active.stop.load(Ordering::Acquire))
{
return Ok(());
}
guard.take()
};
if let Some(dead) = dead {
tear_down(dead);
}
let started = start_record(self.callback.clone())?;
let mut guard = self.lock()?;
if self.generation.load(Ordering::Acquire) != generation {
drop(guard);
tear_down(started);
return Err(generic_error(
"InputHook.start failed: stopped during start",
));
}
if guard
.as_ref()
.is_some_and(|active| !active.stop.load(Ordering::Acquire))
{
drop(guard);
tear_down(started);
return Ok(());
}
let replaced = guard.replace(started);
drop(guard);
if let Some(dead) = replaced {
tear_down(dead);
}
Ok(())
}
fn stop(&self) -> Result<Option<Active>> {
let mut guard = self.lock()?;
self.generation.fetch_add(1, Ordering::AcqRel);
Ok(guard.take())
}
}
pub struct StartTask {
inner: Arc<Inner>,
generation: u64,
}
impl Task for StartTask {
type Output = ();
type JsValue = ();
fn compute(&mut self) -> Result<Self::Output> {
self.inner.start(self.generation)
}
fn resolve(&mut self, _env: Env, _output: Self::Output) -> Result<Self::JsValue> {
Ok(())
}
}
#[napi]
@@ -214,43 +281,28 @@ impl InputHook {
.build_threadsafe_function::<DecodedEvent>()
.weak::<true>()
.callee_handled::<false>()
.max_queue_size::<EVENT_QUEUE_LIMIT>()
.build()
.map_err(|err| generic_error(format!("failed to create TSFN: {}", err.reason)))?;
Ok(Self {
inner: Arc::new(Inner {
callback: Arc::new(tsfn),
active: Mutex::new(None),
generation: AtomicU64::new(0),
}),
})
}
#[napi]
pub fn start(&self) -> Result<()> {
let mut guard = self
.inner
.active
.lock()
.map_err(|_| generic_error("InputHook lock poisoned"))?;
if guard.is_some() {
return Ok(());
}
let active = start_record(self.inner.callback.clone())?;
*guard = Some(active);
Ok(())
pub fn start(&self) -> AsyncTask<StartTask> {
AsyncTask::new(StartTask {
inner: self.inner.clone(),
generation: self.inner.generation.load(Ordering::Acquire),
})
}
#[napi]
pub fn stop(&self) -> Result<()> {
let active = {
let mut guard = self
.inner
.active
.lock()
.map_err(|_| generic_error("InputHook lock poisoned"))?;
guard.take()
};
if let Some(active) = active {
if let Some(active) = self.inner.stop()? {
tear_down(active);
}
Ok(())
@@ -259,9 +311,7 @@ impl InputHook {
impl Drop for InputHook {
fn drop(&mut self) {
if let Ok(mut guard) = self.inner.active.lock()
&& let Some(active) = guard.take()
{
if let Ok(Some(active)) = self.inner.stop() {
tear_down(active);
}
}
@@ -304,18 +354,8 @@ fn start_record(callback: EventTsfn) -> Result<Active> {
generic_error(format!("InputHook.start failed: RecordQueryVersion: {err}"))
})?;
let setup = ctrl_conn.setup();
let min_keycode = setup.min_keycode;
let max_keycode = setup.max_keycode;
let count = max_keycode.saturating_sub(min_keycode).saturating_add(1);
let mapping = ctrl_conn
.get_keyboard_mapping(min_keycode, count)
.map_err(|err| generic_error(format!("InputHook.start failed: GetKeyboardMapping: {err}")))?
.reply()
.map_err(|err| {
generic_error(format!("InputHook.start failed: GetKeyboardMapping: {err}"))
})?;
let keysyms = KeysymCache::build(&mapping, min_keycode);
let keysyms = KeysymCache::fetch(&ctrl_conn)
.map_err(|err| generic_error(format!("InputHook.start failed: {err}")))?;
let record_ctx = ctrl_conn
.generate_id()
@@ -333,7 +373,7 @@ fn start_record(callback: EventTsfn) -> Result<Active> {
delivered_events: empty,
device_events: Range8 {
first: KEY_PRESS,
last: MOTION_NOTIFY,
last: BUTTON_RELEASE,
},
errors: empty,
client_started: false,
@@ -354,168 +394,248 @@ fn start_record(callback: EventTsfn) -> Result<Active> {
})?;
let stop = Arc::new(AtomicBool::new(false));
let (ready_tx, ready_rx) = mpsc::sync_channel(1);
let worker_ctrl = ctrl_conn.clone();
let worker_callback = callback.clone();
let worker_stop = stop.clone();
let worker = thread::Builder::new()
.name("fluxer-linux-input-hook".to_string())
.spawn(move || {
worker_main(data_conn, record_ctx, keysyms, worker_callback, worker_stop);
})
.map_err(|err| generic_error(format!("InputHook.start failed: thread spawn: {err}")))?;
worker_main(
&data_conn,
Worker {
ctrl_conn: worker_ctrl,
record_ctx,
keysyms,
callback: worker_callback,
stop: worker_stop,
ready: Some(ready_tx),
held: HeldInputs::default(),
},
);
});
let worker = match worker {
Ok(worker) => worker,
Err(err) => {
let _ = ctrl_conn.record_free_context(record_ctx);
let _ = ctrl_conn.sync();
return Err(generic_error(format!(
"InputHook.start failed: thread spawn: {err}"
)));
}
};
Ok(Active {
let active = Active {
ctrl_conn,
record_ctx,
worker: Some(worker),
stop,
})
};
let failure = match ready_rx.recv_timeout(START_TIMEOUT) {
Ok(Ok(())) => return Ok(active),
Ok(Err(reason)) => reason,
Err(mpsc::RecvTimeoutError::Timeout) => "RecordEnableContext timed out".to_string(),
Err(mpsc::RecvTimeoutError::Disconnected) => {
"RecordEnableContext worker exited".to_string()
}
};
abandon(active);
Err(generic_error(format!("InputHook.start failed: {failure}")))
}
fn worker_main(
data_conn: RustConnection,
struct Worker {
ctrl_conn: Arc<RustConnection>,
record_ctx: record::Context,
keysyms: KeysymCache,
callback: EventTsfn,
stop: Arc<AtomicBool>,
) {
let cookie = match data_conn.record_enable_context(record_ctx) {
ready: Option<SyncSender<std::result::Result<(), String>>>,
held: HeldInputs,
}
#[derive(Default)]
struct HeldInputs {
keys: BTreeSet<u8>,
buttons: BTreeSet<u8>,
}
impl HeldInputs {
fn track(&mut self, event: &DecodedEvent) {
match event.kind {
EventKind::KeyDown => {
self.keys.insert(event.x11_keycode);
}
EventKind::KeyUp => {
self.keys.remove(&event.x11_keycode);
}
EventKind::MouseDown => {
self.buttons.insert(event.button);
}
EventKind::MouseUp => {
self.buttons.remove(&event.button);
}
}
}
fn take_releases(&mut self, keysyms: &KeysymCache) -> Vec<DecodedEvent> {
let mods = modifiers::from_state(0);
let keys = std::mem::take(&mut self.keys)
.into_iter()
.map(|x11_keycode| key_event(EventKind::KeyUp, x11_keycode, keysyms, mods));
let buttons = std::mem::take(&mut self.buttons).into_iter().map(|button| {
let mut event = DecodedEvent::new(EventKind::MouseUp, mods);
event.button = button;
event
});
keys.chain(buttons).collect()
}
}
impl Worker {
fn report_ready(&mut self, result: std::result::Result<(), String>) {
if let Some(ready) = self.ready.take() {
let _ = ready.send(result);
}
}
fn refresh_keysyms_on_mapping_change(&mut self, data_conn: &RustConnection) {
let mut keyboard_changed = false;
while let Ok(Some(event)) = data_conn.poll_for_event() {
if let Event::MappingNotify(notify) = event
&& notify.request == Mapping::KEYBOARD
{
keyboard_changed = true;
}
}
if !keyboard_changed {
return;
}
if let Ok(keysyms) = KeysymCache::fetch(&self.ctrl_conn) {
self.keysyms = keysyms;
}
while let Ok(Some(_)) = self.ctrl_conn.poll_for_event() {}
}
}
fn worker_main(data_conn: &RustConnection, mut worker: Worker) {
let cookie = match data_conn.record_enable_context(worker.record_ctx) {
Ok(c) => c,
Err(_) => {
stop.store(true, Ordering::Release);
Err(err) => {
worker.stop.store(true, Ordering::Release);
worker.report_ready(Err(format!("RecordEnableContext: {err}")));
return;
}
};
for reply in cookie {
if stop.load(Ordering::Acquire) {
if worker.stop.load(Ordering::Acquire) {
break;
}
let reply = match reply {
Ok(r) => r,
Err(err) if worker.ready.is_some() => {
worker.report_ready(Err(format!("RecordEnableContext: {err}")));
break;
}
Err(ReplyError::ConnectionError(_)) => break,
Err(_) => continue,
};
if reply.client_swapped {
if reply.category == RECORD_START_OF_DATA {
worker.report_ready(Ok(()));
continue;
}
match reply.category {
RECORD_START_OF_DATA => continue,
RECORD_FROM_SERVER => {}
_ => continue,
if reply.client_swapped || reply.category != RECORD_FROM_SERVER {
continue;
}
worker.refresh_keysyms_on_mapping_change(data_conn);
let mut data: &[u8] = &reply.data;
while !data.is_empty() {
let consumed = decode_one(data, &keysyms, &callback, &stop);
let (consumed, event) = decode_one(data, &worker.keysyms);
if let Some(event) = event {
worker.held.track(&event);
dispatch(&worker.callback, &worker.stop, event);
}
if consumed == 0 || consumed > data.len() {
break;
}
data = &data[consumed..];
}
}
if !worker.stop.swap(true, Ordering::AcqRel) {
for event in worker.held.take_releases(&worker.keysyms) {
dispatch(&worker.callback, &worker.stop, event);
}
}
worker.report_ready(Err("RecordEnableContext ended before data".to_string()));
}
fn decode_one(
data: &[u8],
keysyms: &KeysymCache,
callback: &EventTsfn,
stop: &Arc<AtomicBool>,
) -> usize {
fn decode_one(data: &[u8], keysyms: &KeysymCache) -> (usize, Option<DecodedEvent>) {
if data.is_empty() {
return 0;
return (0, None);
}
let type_ = data[0];
match type_ {
KEY_PRESS | KEY_RELEASE | BUTTON_PRESS | BUTTON_RELEASE | MOTION_NOTIFY => {
match data[0] {
KEY_PRESS | KEY_RELEASE | BUTTON_PRESS | BUTTON_RELEASE => {
if data.len() < std::mem::size_of::<XKeyButtonProto>() {
return 0;
return (0, None);
}
let evt: XKeyButtonProto =
unsafe { std::ptr::read_unaligned(data.as_ptr() as *const XKeyButtonProto) };
handle_event(&evt, keysyms, callback, stop);
32
(32, decode_event(&evt, keysyms))
}
0 => {
if data.len() < 8 {
return 0;
return (0, None);
}
let (length, _) = match u32::try_parse(&data[4..]) {
Ok(v) => v,
Err(_) => return 0,
Err(_) => return (0, None),
};
32 + (length as usize) * 4
(32 + (length as usize) * 4, None)
}
_ => 32,
_ => (32, None),
}
}
fn handle_event(
evt: &XKeyButtonProto,
fn key_event(
kind: EventKind,
x11_keycode: u8,
keysyms: &KeysymCache,
callback: &EventTsfn,
stop: &Arc<AtomicBool>,
) {
mods: Modifiers,
) -> DecodedEvent {
let keysym = keysyms.lookup(x11_keycode);
let mut event = DecodedEvent::new(kind, mods);
event.keycode = keysym;
event.x11_keycode = x11_keycode;
event.key_name = keymap::keysym_to_name(keysym)
.unwrap_or_default()
.to_string();
event
}
fn decode_event(evt: &XKeyButtonProto, keysyms: &KeysymCache) -> Option<DecodedEvent> {
let mods = modifiers::from_state(u32::from(evt.state));
match evt.type_ {
KEY_PRESS | KEY_RELEASE => {
let _lookup = x11::xkb_lookup_for_base();
let keysym = keysyms.lookup(evt.detail);
KEY_PRESS => Some(key_event(EventKind::KeyDown, evt.detail, keysyms, mods)),
KEY_RELEASE => Some(key_event(EventKind::KeyUp, evt.detail, keysyms, mods)),
BUTTON_PRESS | BUTTON_RELEASE => {
let button = mouse::browser_button(u32::from(evt.detail))?;
let mut event = DecodedEvent::new(
if evt.type_ == KEY_PRESS {
EventKind::KeyDown
if evt.type_ == BUTTON_PRESS {
EventKind::MouseDown
} else {
EventKind::KeyUp
EventKind::MouseUp
},
mods,
);
event.keycode = keysym;
event.key_name = match keymap::keysym_to_name(keysym) {
Some(name) => name.to_string(),
None => keymap::fallback_name(keysym),
};
dispatch(callback, stop, event);
}
BUTTON_PRESS | BUTTON_RELEASE => {
let cls = mouse::classify(u32::from(evt.detail));
match cls {
MouseClassification::Button(b) => {
let mut event = DecodedEvent::new(
if evt.type_ == BUTTON_PRESS {
EventKind::MouseDown
} else {
EventKind::MouseUp
},
mods,
);
event.button = b;
event.x = i32::from(evt.root_x);
event.y = i32::from(evt.root_y);
event.has_xy = true;
dispatch(callback, stop, event);
}
MouseClassification::Wheel(dir) => {
if evt.type_ == BUTTON_PRESS {
let mut event = DecodedEvent::new(EventKind::Wheel, mods);
event.delta_x = dir.delta_x();
event.delta_y = dir.delta_y();
event.x = i32::from(evt.root_x);
event.y = i32::from(evt.root_y);
event.has_xy = true;
dispatch(callback, stop, event);
}
}
MouseClassification::Ignored => {}
}
}
MOTION_NOTIFY => {
let mut event = DecodedEvent::new(EventKind::MouseMove, mods);
event.button = button;
event.x = i32::from(evt.root_x);
event.y = i32::from(evt.root_y);
event.has_xy = true;
dispatch(callback, stop, event);
Some(event)
}
_ => {}
_ => None,
}
}
@@ -526,6 +646,13 @@ fn dispatch(callback: &EventTsfn, stop: &Arc<AtomicBool>, event: DecodedEvent) {
}
}
fn abandon(active: Active) {
active.stop.store(true, Ordering::Release);
let _ = active.ctrl_conn.record_disable_context(active.record_ctx);
let _ = active.ctrl_conn.record_free_context(active.record_ctx);
let _ = active.ctrl_conn.flush();
}
fn tear_down(mut active: Active) {
active.stop.store(true, Ordering::Release);
@@ -550,11 +677,6 @@ pub fn is_available() -> bool {
x11rb::connect(None).is_ok()
}
#[allow(dead_code)]
pub(crate) fn detected_display_server() -> DisplayServer {
detect_display_server()
}
fn generic_error(reason: impl Into<String>) -> napi::Error {
napi::Error::new(Status::GenericFailure, reason.into())
}
@@ -578,26 +700,78 @@ const _ASSERT_PROTO_LAYOUT: fn() = || {
mod tests {
use super::*;
fn cache_with(x11_keycode: u8, keysym: u32) -> KeysymCache {
let mut syms = vec![0; 248];
syms[usize::from(x11_keycode - 8)] = keysym;
KeysymCache {
min_keycode: 8,
syms,
}
}
fn wire_event(type_: u8, detail: u8, state: u16) -> [u8; 32] {
let mut data = [0u8; 32];
data[0] = type_;
data[1] = detail;
data[28..30].copy_from_slice(&state.to_ne_bytes());
data
}
#[test]
fn decoded_keydown_has_keysym_and_name() {
let mut event = DecodedEvent::new(EventKind::KeyDown, modifiers::from_state(0));
event.keycode = 0x0061;
event.key_name = keymap::keysym_to_name(0x0061).unwrap().to_string();
fn decoded_keydown_has_keysym_raw_keycode_and_name() {
let (consumed, event) = decode_one(&wire_event(KEY_PRESS, 38, 0), &cache_with(38, 0x0061));
let event = event.expect("key event");
assert_eq!(consumed, 32);
assert!(matches!(event.kind, EventKind::KeyDown));
assert_eq!(event.keycode, 0x0061);
assert_eq!(event.x11_keycode, 38);
assert_eq!(event.key_name, "A");
assert!(!event.has_xy);
}
#[test]
fn decoded_wheel_uses_120_step_deltas() {
let dir = mouse::WheelDirection::Down;
let mut event = DecodedEvent::new(EventKind::Wheel, modifiers::from_state(0));
event.delta_x = dir.delta_x();
event.delta_y = dir.delta_y();
event.has_xy = true;
assert_eq!(event.delta_x, 0);
assert_eq!(event.delta_y, 120);
fn unknown_keysym_has_no_layout_key_name() {
let (_, event) = decode_one(&wire_event(KEY_PRESS, 24, 0x4), &cache_with(24, 0x6ca));
let event = event.expect("key event");
assert_eq!(event.keycode, 0x6ca);
assert_eq!(event.x11_keycode, 24);
assert_eq!(event.key_name, "");
assert!(event.mods.ctrl);
}
#[test]
fn wheel_buttons_are_not_decoded() {
let cache = cache_with(38, 0x0061);
assert!(
decode_one(&wire_event(BUTTON_PRESS, 4, 0), &cache)
.1
.is_none()
);
let (_, event) = decode_one(&wire_event(BUTTON_PRESS, 8, 0), &cache);
assert_eq!(event.expect("button event").button, 3);
}
#[test]
fn held_inputs_release_every_pressed_key_and_button_once() {
let cache = cache_with(38, 0x0061);
let mut held = HeldInputs::default();
for data in [
wire_event(KEY_PRESS, 38, 0),
wire_event(KEY_PRESS, 37, 0),
wire_event(KEY_RELEASE, 37, 0),
wire_event(BUTTON_PRESS, 9, 0),
] {
held.track(&decode_one(&data, &cache).1.expect("event"));
}
let releases = held.take_releases(&cache);
assert_eq!(releases.len(), 2);
assert!(matches!(releases[0].kind, EventKind::KeyUp));
assert_eq!(releases[0].x11_keycode, 38);
assert_eq!(releases[0].key_name, "A");
assert!(!releases[0].mods.ctrl);
assert!(matches!(releases[1].kind, EventKind::MouseUp));
assert_eq!(releases[1].button, 4);
assert!(held.take_releases(&cache).is_empty());
}
#[test]
@@ -614,25 +788,10 @@ mod tests {
#[test]
fn event_kind_to_string_matches_js_contract() {
let cases: &[(EventKind, &str)] = &[
(EventKind::KeyDown, "keydown"),
(EventKind::KeyUp, "keyup"),
(EventKind::MouseDown, "mousedown"),
(EventKind::MouseUp, "mouseup"),
(EventKind::MouseMove, "mousemove"),
(EventKind::Wheel, "wheel"),
];
for (kind, expected) in cases {
let label = match kind {
EventKind::KeyDown => "keydown",
EventKind::KeyUp => "keyup",
EventKind::MouseDown => "mousedown",
EventKind::MouseUp => "mouseup",
EventKind::MouseMove => "mousemove",
EventKind::Wheel => "wheel",
};
assert_eq!(label, *expected);
}
assert_eq!(EventKind::KeyDown.as_str(), "keydown");
assert_eq!(EventKind::KeyUp.as_str(), "keyup");
assert_eq!(EventKind::MouseDown.as_str(), "mousedown");
assert_eq!(EventKind::MouseUp.as_str(), "mouseup");
}
#[test]
@@ -157,10 +157,6 @@ pub fn keysym_to_name(keysym: u32) -> Option<&'static str> {
.map(|(_, name)| *name)
}
pub fn fallback_name(keysym: u32) -> String {
format!("Key{keysym}")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -200,9 +196,9 @@ mod tests {
}
#[test]
fn unknown_keysym_falls_back_to_key_n() {
fn unknown_keysym_has_no_name() {
assert!(keysym_to_name(0x12345).is_none());
assert_eq!(fallback_name(0x12345), "Key74565");
assert!(keysym_to_name(0x6ca).is_none());
}
#[test]
@@ -6,7 +6,6 @@ pub mod env;
pub mod keymap;
pub mod modifiers;
pub mod mouse;
pub mod x11;
#[cfg(target_os = "linux")]
mod hook;
@@ -1,51 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WheelDirection {
Up,
Down,
Left,
Right,
}
impl WheelDirection {
pub fn delta_x(self) -> i32 {
match self {
Self::Left => -120,
Self::Right => 120,
_ => 0,
}
}
pub fn delta_y(self) -> i32 {
match self {
Self::Up => -120,
Self::Down => 120,
_ => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MouseClassification {
Button(u8),
Wheel(WheelDirection),
Ignored,
}
pub fn classify(x11_button: u32) -> MouseClassification {
pub fn browser_button(x11_button: u32) -> Option<u8> {
match x11_button {
1 => MouseClassification::Button(0),
2 => MouseClassification::Button(1),
3 => MouseClassification::Button(2),
4 => MouseClassification::Wheel(WheelDirection::Up),
5 => MouseClassification::Wheel(WheelDirection::Down),
6 => MouseClassification::Wheel(WheelDirection::Left),
7 => MouseClassification::Wheel(WheelDirection::Right),
8 => MouseClassification::Button(3),
9 => MouseClassification::Button(4),
_ => MouseClassification::Ignored,
1 => Some(0),
2 => Some(1),
3 => Some(2),
8 => Some(3),
9 => Some(4),
_ => None,
}
}
@@ -55,36 +17,28 @@ mod tests {
#[test]
fn primary_buttons_map_to_browser_indices() {
assert_eq!(classify(1), MouseClassification::Button(0));
assert_eq!(classify(2), MouseClassification::Button(1));
assert_eq!(classify(3), MouseClassification::Button(2));
}
#[test]
fn vertical_wheel_delta_y_120_step() {
assert_eq!(classify(4), MouseClassification::Wheel(WheelDirection::Up));
assert_eq!(WheelDirection::Up.delta_y(), -120);
assert_eq!(WheelDirection::Down.delta_y(), 120);
assert_eq!(WheelDirection::Up.delta_x(), 0);
}
#[test]
fn horizontal_wheel_delta_x_120_step() {
assert_eq!(WheelDirection::Left.delta_x(), -120);
assert_eq!(WheelDirection::Right.delta_x(), 120);
assert_eq!(WheelDirection::Left.delta_y(), 0);
assert_eq!(browser_button(1), Some(0));
assert_eq!(browser_button(2), Some(1));
assert_eq!(browser_button(3), Some(2));
}
#[test]
fn back_forward_buttons_map_to_3_and_4() {
assert_eq!(classify(8), MouseClassification::Button(3));
assert_eq!(classify(9), MouseClassification::Button(4));
assert_eq!(browser_button(8), Some(3));
assert_eq!(browser_button(9), Some(4));
}
#[test]
fn wheel_buttons_are_ignored() {
for button in 4..=7 {
assert_eq!(browser_button(button), None);
}
}
#[test]
fn unknown_buttons_are_ignored() {
assert_eq!(classify(0), MouseClassification::Ignored);
assert_eq!(classify(15), MouseClassification::Ignored);
assert_eq!(classify(255), MouseClassification::Ignored);
assert_eq!(browser_button(0), None);
assert_eq!(browser_button(15), None);
assert_eq!(browser_button(255), None);
}
}
@@ -1,23 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#[derive(Debug, Clone, Copy)]
pub struct XkbLookup {
pub group: u32,
pub level: u32,
}
pub fn xkb_lookup_for_base() -> XkbLookup {
XkbLookup { group: 0, level: 0 }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xkb_lookup_for_base_pins_group_level_to_zero() {
let lookup = xkb_lookup_for_base();
assert_eq!(lookup.group, 0);
assert_eq!(lookup.level, 0);
}
}