Files
fluxer-desktop-patched/fluxer_desktop/native/linux-input-hook/src/hook.rs
T
brenden 682afacd30 Add native self-hosted instance connection to fluxer_desktop
Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 +
tools/ci) with a "Connect to a Different Server" menu item and popout
that lets the desktop app switch to any self-hosted Fluxer instance,
plus fixes for well-known discovery on single-domain self-hosted
deployments and a false-positive ERR_ABORTED on same-origin client
redirects during the switch. Defaults to chat.fluxr.chat and uses an
isolated userData directory from the official build.
2026-07-01 18:22:43 -04:00

654 lines
19 KiB
Rust

#![allow(non_snake_case)]
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use napi::{
Env, Status,
bindgen_prelude::{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::record::{
self, ConnectionExt as RecordConnectionExt, ExtRange, Range, Range8, Range16,
};
use x11rb::protocol::xproto::{
ConnectionExt as XprotoConnectionExt, GetKeyboardMappingReply, Keycode,
};
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::keymap;
use crate::modifiers::{self, Modifiers};
use crate::mouse::{self, MouseClassification};
use crate::x11;
const RECORD_FROM_SERVER: u8 = 0;
const RECORD_START_OF_DATA: u8 = 4;
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;
#[repr(C)]
#[derive(Debug, Clone, Copy)]
struct XKeyButtonProto {
type_: u8,
detail: u8,
seq_l: u8,
seq_h: u8,
time: u32,
root: u32,
event: u32,
child: u32,
root_x: i16,
root_y: i16,
event_x: i16,
event_y: i16,
state: u16,
same_screen: u8,
pad0: u8,
}
#[derive(Debug, Clone)]
pub enum EventKind {
KeyDown,
KeyUp,
MouseDown,
MouseUp,
MouseMove,
Wheel,
}
#[derive(Debug, Clone)]
pub struct DecodedEvent {
pub kind: EventKind,
pub keycode: u32,
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,
pub mods: Modifiers,
}
impl DecodedEvent {
fn new(kind: EventKind, mods: Modifiers) -> Self {
Self {
kind,
keycode: 0,
key_name: String::new(),
button: 0,
delta_x: 0,
delta_y: 0,
x: 0,
y: 0,
has_xy: false,
mods,
}
}
}
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("ctrlKey", event.mods.ctrl)?;
object.set("altKey", event.mods.alt)?;
object.set("shiftKey", event.mods.shift)?;
object.set("metaKey", event.mods.meta)?;
match event.kind {
EventKind::KeyDown | EventKind::KeyUp => {
object.set("keycode", event.keycode)?;
object.set("keyName", event.key_name.as_str())?;
}
EventKind::MouseDown | EventKind::MouseUp => {
object.set("button", u32::from(event.button))?;
if event.has_xy {
object.set("x", event.x)?;
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,
>,
>;
#[derive(Clone)]
struct KeysymCache {
min_keycode: Keycode,
syms: Vec<u32>,
}
impl KeysymCache {
fn build(reply: &GetKeyboardMappingReply, min_keycode: Keycode) -> Self {
let per = reply.keysyms_per_keycode as usize;
let count = if per == 0 {
0
} else {
reply.keysyms.len() / per
};
let mut syms = Vec::with_capacity(count);
if per > 0 {
for i in 0..count {
syms.push(reply.keysyms[i * per]);
}
}
Self { min_keycode, syms }
}
fn lookup(&self, keycode: u8) -> u32 {
if keycode < self.min_keycode {
return 0;
}
let idx = (keycode - self.min_keycode) as usize;
self.syms.get(idx).copied().unwrap_or(0)
}
}
struct Active {
ctrl_conn: Arc<RustConnection>,
record_ctx: record::Context,
worker: Option<JoinHandle<()>>,
stop: Arc<AtomicBool>,
}
struct Inner {
callback: EventTsfn,
active: Mutex<Option<Active>>,
}
#[napi]
pub struct InputHook {
inner: Arc<Inner>,
}
#[napi]
impl InputHook {
#[napi(constructor)]
pub fn new(callback: Function<DecodedEvent, UnknownReturnValue>) -> Result<Self> {
let tsfn = callback
.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),
}),
})
}
#[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(())
}
#[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 {
tear_down(active);
}
Ok(())
}
}
impl Drop for InputHook {
fn drop(&mut self) {
if let Ok(mut guard) = self.inner.active.lock()
&& let Some(active) = guard.take()
{
tear_down(active);
}
}
}
fn start_record(callback: EventTsfn) -> Result<Active> {
if !detect_display_server().supports_global_xrecord() {
return Err(generic_error(
"InputHook.start failed: WaylandUnsupported — global input \
capture is blocked by the Wayland security model. Use \
@fluxer/linux-evdev for kernel-level capture when the user has \
input device access."
.to_string(),
));
}
let (ctrl_conn, _) = x11rb::connect(None)
.map_err(|err| generic_error(format!("InputHook.start failed: NoXDisplay: {err}")))?;
let (data_conn, _) = x11rb::connect(None)
.map_err(|err| generic_error(format!("InputHook.start failed: NoXDisplay: {err}")))?;
let ctrl_conn = Arc::new(ctrl_conn);
let has_record = ctrl_conn
.extension_information(record::X11_EXTENSION_NAME)
.map_err(|err| generic_error(format!("InputHook.start failed: {err}")))?
.is_some();
if !has_record {
return Err(generic_error(
"InputHook.start failed: RecordExtensionUnavailable",
));
}
ctrl_conn
.record_query_version(
record::X11_XML_VERSION.0 as _,
record::X11_XML_VERSION.1 as _,
)
.map_err(|err| generic_error(format!("InputHook.start failed: RecordQueryVersion: {err}")))?
.reply()
.map_err(|err| {
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 record_ctx = ctrl_conn
.generate_id()
.map_err(|err| generic_error(format!("InputHook.start failed: GenerateId: {err}")))?;
let empty = Range8 { first: 0, last: 0 };
let empty_ext = ExtRange {
major: empty,
minor: Range16 { first: 0, last: 0 },
};
let range = Range {
core_requests: empty,
core_replies: empty,
ext_requests: empty_ext,
ext_replies: empty_ext,
delivered_events: empty,
device_events: Range8 {
first: KEY_PRESS,
last: MOTION_NOTIFY,
},
errors: empty,
client_started: false,
client_died: false,
};
ctrl_conn
.record_create_context(record_ctx, 0, &[record::CS::ALL_CLIENTS.into()], &[range])
.map_err(|err| {
generic_error(format!(
"InputHook.start failed: RecordCreateContext: {err}"
))
})?
.check()
.map_err(|err| {
generic_error(format!(
"InputHook.start failed: RecordCreateContext: {err}"
))
})?;
let stop = Arc::new(AtomicBool::new(false));
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}")))?;
Ok(Active {
ctrl_conn,
record_ctx,
worker: Some(worker),
stop,
})
}
fn worker_main(
data_conn: RustConnection,
record_ctx: record::Context,
keysyms: KeysymCache,
callback: EventTsfn,
stop: Arc<AtomicBool>,
) {
let cookie = match data_conn.record_enable_context(record_ctx) {
Ok(c) => c,
Err(_) => {
stop.store(true, Ordering::Release);
return;
}
};
for reply in cookie {
if stop.load(Ordering::Acquire) {
break;
}
let reply = match reply {
Ok(r) => r,
Err(ReplyError::ConnectionError(_)) => break,
Err(_) => continue,
};
if reply.client_swapped {
continue;
}
match reply.category {
RECORD_START_OF_DATA => continue,
RECORD_FROM_SERVER => {}
_ => continue,
}
let mut data: &[u8] = &reply.data;
while !data.is_empty() {
let consumed = decode_one(data, &keysyms, &callback, &stop);
if consumed == 0 || consumed > data.len() {
break;
}
data = &data[consumed..];
}
}
}
fn decode_one(
data: &[u8],
keysyms: &KeysymCache,
callback: &EventTsfn,
stop: &Arc<AtomicBool>,
) -> usize {
if data.is_empty() {
return 0;
}
let type_ = data[0];
match type_ {
KEY_PRESS | KEY_RELEASE | BUTTON_PRESS | BUTTON_RELEASE | MOTION_NOTIFY => {
if data.len() < std::mem::size_of::<XKeyButtonProto>() {
return 0;
}
let evt: XKeyButtonProto =
unsafe { std::ptr::read_unaligned(data.as_ptr() as *const XKeyButtonProto) };
handle_event(&evt, keysyms, callback, stop);
32
}
0 => {
if data.len() < 8 {
return 0;
}
let (length, _) = match u32::try_parse(&data[4..]) {
Ok(v) => v,
Err(_) => return 0,
};
32 + (length as usize) * 4
}
_ => 32,
}
}
fn handle_event(
evt: &XKeyButtonProto,
keysyms: &KeysymCache,
callback: &EventTsfn,
stop: &Arc<AtomicBool>,
) {
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);
let mut event = DecodedEvent::new(
if evt.type_ == KEY_PRESS {
EventKind::KeyDown
} else {
EventKind::KeyUp
},
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.x = i32::from(evt.root_x);
event.y = i32::from(evt.root_y);
event.has_xy = true;
dispatch(callback, stop, event);
}
_ => {}
}
}
fn dispatch(callback: &EventTsfn, stop: &Arc<AtomicBool>, event: DecodedEvent) {
let status = callback.call(event, ThreadsafeFunctionCallMode::NonBlocking);
if status == Status::Closing {
stop.store(true, Ordering::Release);
}
}
fn tear_down(mut active: Active) {
active.stop.store(true, Ordering::Release);
if active.record_ctx != 0 {
let _ = active.ctrl_conn.record_disable_context(active.record_ctx);
let _ = active.ctrl_conn.sync();
}
if let Some(worker) = active.worker.take() {
let _ = worker.join();
}
if active.record_ctx != 0 {
let _ = active.ctrl_conn.record_free_context(active.record_ctx);
let _ = active.ctrl_conn.sync();
}
}
#[napi(js_name = "isAvailable")]
pub fn is_available() -> bool {
if !detect_display_server().supports_global_xrecord() {
return false;
}
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())
}
#[allow(dead_code)]
const _ASSERT_PROTO_LAYOUT: fn() = || {
use std::mem::offset_of;
let _ = offset_of!(XKeyButtonProto, seq_l);
let _ = offset_of!(XKeyButtonProto, seq_h);
let _ = offset_of!(XKeyButtonProto, event);
let _ = offset_of!(XKeyButtonProto, child);
let _ = offset_of!(XKeyButtonProto, event_x);
let _ = offset_of!(XKeyButtonProto, event_y);
let _ = offset_of!(XKeyButtonProto, same_screen);
let _ = offset_of!(XKeyButtonProto, pad0);
let _ = offset_of!(XKeyButtonProto, root);
let _ = offset_of!(XKeyButtonProto, time);
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decoded_keydown_carries_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();
assert!(matches!(event.kind, EventKind::KeyDown));
assert_eq!(event.keycode, 0x0061);
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);
}
#[test]
fn x_key_button_proto_layout_offsets_match_x11_wire_format() {
use std::mem::offset_of;
assert_eq!(offset_of!(XKeyButtonProto, type_), 0);
assert_eq!(offset_of!(XKeyButtonProto, detail), 1);
assert_eq!(offset_of!(XKeyButtonProto, time), 4);
assert_eq!(offset_of!(XKeyButtonProto, root_x), 20);
assert_eq!(offset_of!(XKeyButtonProto, root_y), 22);
assert_eq!(offset_of!(XKeyButtonProto, state), 28);
}
#[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);
}
}
#[test]
fn keysym_cache_returns_zero_below_min_keycode() {
let cache = KeysymCache {
min_keycode: 8,
syms: vec![0x61, 0x62, 0x63],
};
assert_eq!(cache.lookup(7), 0);
assert_eq!(cache.lookup(8), 0x61);
assert_eq!(cache.lookup(10), 0x63);
assert_eq!(cache.lookup(255), 0);
}
}