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

@@ -185,14 +185,6 @@ pub fn keycode_to_name(code: u16) -> Option<&'static str> {
.map(|(_, name)| *name)
}
pub fn name_to_keycode(name: &str) -> u16 {
KEY_MAP
.iter()
.find(|(_, entry_name)| *entry_name == name)
.map(|(code, _)| *code)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -211,15 +203,6 @@ mod tests {
assert_eq!(keycode_to_name(0xffff), None);
}
#[test]
fn name_to_keycode_round_trips_every_entry() {
for (code, name) in KEY_MAP {
assert_eq!(name_to_keycode(name), *code);
}
assert_eq!(name_to_keycode("NoSuchKey"), 0);
assert_eq!(name_to_keycode(""), 0);
}
#[test]
fn evdev_button_matches_dom_convention() {
assert_eq!(evdev_button_to_browser_button(BTN_LEFT), Some(0));
+7 -4
View File
@@ -8,10 +8,13 @@ mod keymap;
mod linux;
#[cfg(target_os = "linux")]
pub use linux::{EvdevHook, name_to_evdev_keycode};
mod logind;
#[cfg(target_os = "linux")]
pub use linux::{EvdevHook, is_keyboard_readable};
#[cfg(not(target_os = "linux"))]
#[napi_derive::napi(js_name = "nameToEvdevKeycode")]
pub fn name_to_evdev_keycode(name: String) -> u32 {
u32::from(crate::keymap::name_to_keycode(&name))
#[napi_derive::napi(js_name = "isKeyboardReadable")]
pub fn is_keyboard_readable() -> bool {
false
}
+449 -111
View File
@@ -2,16 +2,22 @@
use std::{
collections::{HashMap, HashSet},
os::fd::{AsFd, AsRawFd, BorrowedFd, RawFd},
ffi::OsStr,
ops::Range,
os::{
fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd, RawFd},
unix::fs::OpenOptionsExt,
},
path::{Path, PathBuf},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
use evdev::{Device, EventType, KeyCode};
use evdev::{EventType, KeyCode, SynchronizationCode, raw_stream::RawDevice};
use napi::{
Env, Status,
bindgen_prelude::{Function, Object, Result, ToNapiValue},
@@ -19,16 +25,28 @@ use napi::{
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode, UnknownReturnValue},
};
use napi_derive::napi;
use nix::sys::eventfd::{EfdFlags, EventFd};
use nix::{
fcntl::OFlag,
sys::{
eventfd::{EfdFlags, EventFd},
inotify::{AddWatchFlags, InitFlags, Inotify},
},
};
use polling::{Event as PollEvent, Events, Poller};
use crate::keymap;
use crate::logind::{SessionGate, SessionWatch};
const EXIT_KEY: usize = 0;
const MONITOR_KEY: usize = 1;
const DEVICE_KEY_BASE: usize = 2;
const INOTIFY_KEY: usize = 2;
const DEVICE_KEY_BASE: usize = 3;
const DEV_INPUT_DIR: &str = "/dev/input";
const DEV_INPUT_PREFIX: &str = "/dev/input/event";
const EVENT_QUEUE_LIMIT: usize = 1024;
const EVENT_NODE_PREFIX: &str = "event";
const FLATPAK_INFO_PATH: &str = "/.flatpak-info";
const PERMISSION_RETRY_DELAY: Duration = Duration::from_millis(500);
const FULL_KEYBOARD_KEY_CODES: std::ops::Range<u16> = 1..32;
fn poll_key_for_device_fd(fd: RawFd) -> Option<usize> {
usize::try_from(fd).ok()?.checked_add(DEVICE_KEY_BASE)
@@ -126,17 +144,8 @@ impl ToNapiValue for NativeEvent {
}
}
type EventTsfn = Arc<
ThreadsafeFunction<
NativeEvent,
UnknownReturnValue,
NativeEvent,
Status,
false,
true,
EVENT_QUEUE_LIMIT,
>,
>;
type EventTsfn =
Arc<ThreadsafeFunction<NativeEvent, UnknownReturnValue, NativeEvent, Status, false, true>>;
struct ExitFd {
fd: EventFd,
@@ -191,18 +200,79 @@ fn lookup_input_seat(sysname: &str) -> Option<String> {
read_seat_from_device(&device)
}
struct SeatFilter {
udev_handle_available: bool,
seat: String,
}
impl SeatFilter {
fn detect() -> Self {
Self {
udev_handle_available: udev::Enumerator::new().is_ok(),
seat: resolve_seat(),
}
}
fn includes(&self, sysname: &str) -> bool {
if !self.udev_handle_available || self.seat.is_empty() {
return true;
}
match lookup_input_seat(sysname) {
Some(found) => found == self.seat,
None => true,
}
}
fn event_node_paths(&self) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(DEV_INPUT_DIR) else {
return Vec::new();
};
entries
.flatten()
.filter(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| is_event_node_name(name) && self.includes(name))
})
.map(|entry| entry.path())
.collect()
}
}
fn is_event_node_name(name: &str) -> bool {
name.starts_with(EVENT_NODE_PREFIX)
}
fn is_sandboxed() -> bool {
Path::new(FLATPAK_INFO_PATH).exists()
}
struct OpenedDevice {
device: Device,
device: RawDevice,
path: PathBuf,
held: HashSet<u16>,
dropped: bool,
}
enum DeviceInput {
Key(u16, i32),
Resync,
}
struct PermissionRetry {
path: PathBuf,
due: Instant,
}
struct Reader {
poller: Arc<Poller>,
exit_fd: Arc<ExitFd>,
udev_handle_available: bool,
seat: String,
gate: Arc<SessionGate>,
seats: SeatFilter,
devices: HashMap<RawFd, OpenedDevice>,
held_keys: HashSet<u16>,
retries: Vec<PermissionRetry>,
delivering: bool,
callback: EventTsfn,
stop: Arc<AtomicBool>,
}
@@ -217,22 +287,31 @@ impl Reader {
unsafe {
poller.add(&exit_fd.as_borrowed(), PollEvent::readable(EXIT_KEY))?;
}
let seat = resolve_seat();
let udev_handle_available = udev::Enumerator::new().is_ok();
let gate = Arc::new(SessionGate::new(poller.clone()));
Ok(Self {
poller,
exit_fd,
udev_handle_available,
seat,
gate,
seats: SeatFilter::detect(),
devices: HashMap::new(),
held_keys: HashSet::new(),
retries: Vec::new(),
delivering: false,
callback,
stop,
})
}
fn has_full_keyboard(&self) -> bool {
self.devices
.values()
.any(|opened| device_is_full_keyboard(&opened.device))
}
fn try_attach_monitor(&self) -> Option<udev::MonitorSocket> {
if is_sandboxed() {
return None;
}
let socket = udev::MonitorBuilder::new()
.and_then(|b| b.match_subsystem("input"))
.and_then(|b| b.listen())
@@ -246,38 +325,62 @@ impl Reader {
}
}
fn device_is_on_our_seat(&self, sysname: &str) -> bool {
if !self.udev_handle_available || self.seat.is_empty() {
return true;
fn try_attach_inotify(&self) -> Option<Inotify> {
let inotify = Inotify::init(InitFlags::IN_CLOEXEC | InitFlags::IN_NONBLOCK).ok()?;
inotify
.add_watch(
DEV_INPUT_DIR,
AddWatchFlags::IN_CREATE | AddWatchFlags::IN_ATTRIB,
)
.ok()?;
if unsafe {
self.poller
.add(&inotify.as_fd(), PollEvent::readable(INOTIFY_KEY))
}
match lookup_input_seat(sysname) {
Some(found) => found == self.seat,
None => true,
.is_ok()
{
Some(inotify)
} else {
None
}
}
fn open_all_devices(&mut self) {
let entries = match std::fs::read_dir("/dev/input") {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = match name.to_str() {
Some(s) => s,
None => continue,
};
if !name_str.starts_with("event") {
continue;
}
if !self.device_is_on_our_seat(name_str) {
continue;
}
let path = entry.path();
for path in self.seats.event_node_paths() {
let _ = self.open_device(&path);
}
}
fn open_hotplugged_device(&mut self, path: &Path) {
if let Err(err) = self.open_device(path)
&& err.kind() == std::io::ErrorKind::PermissionDenied
&& !self.retries.iter().any(|retry| retry.path == path)
{
self.retries.push(PermissionRetry {
path: path.to_path_buf(),
due: Instant::now() + PERMISSION_RETRY_DELAY,
});
}
}
fn next_retry_timeout(&self) -> Option<Duration> {
let now = Instant::now();
self.retries
.iter()
.map(|retry| retry.due.saturating_duration_since(now))
.min()
}
fn run_due_retries(&mut self) {
let now = Instant::now();
let (due, pending): (Vec<PermissionRetry>, Vec<PermissionRetry>) =
self.retries.drain(..).partition(|retry| retry.due <= now);
self.retries = pending;
for retry in due {
let _ = self.open_device(&retry.path);
}
}
fn open_device(&mut self, path: &Path) -> std::io::Result<()> {
if self
.devices
@@ -286,11 +389,10 @@ impl Reader {
{
return Ok(());
}
let device = Device::open(path)?;
let device = open_read_only(path)?;
if !device_has_routable_input(&device) {
return Ok(());
}
let _ = device.set_nonblocking(true);
let fd = device.as_raw_fd();
let poll_key = poll_key_for_device_fd(fd).ok_or_else(|| {
std::io::Error::new(
@@ -307,8 +409,11 @@ impl Reader {
OpenedDevice {
device,
path: path.to_path_buf(),
held: HashSet::new(),
dropped: false,
},
);
self.retries.retain(|retry| retry.path != path);
Ok(())
}
@@ -327,28 +432,65 @@ impl Reader {
if let Some(opened) = self.devices.remove(&fd) {
let borrowed = unsafe { BorrowedFd::borrow_raw(fd) };
let _ = self.poller.delete(borrowed);
drop(opened);
let OpenedDevice { device, held, .. } = opened;
drop(device);
self.release_codes(held);
}
}
fn release_all(&mut self) {
let mut held = HashSet::new();
for opened in self.devices.values_mut() {
held.extend(opened.held.drain());
}
self.release_codes(held);
}
fn release_codes(&mut self, codes: HashSet<u16>) {
let mut codes: Vec<u16> = codes
.into_iter()
.filter(|code| !self.is_held(*code))
.collect();
codes.sort_unstable();
for code in codes {
self.dispatch(self.native_event(code, false));
}
}
fn apply_session_gate(&mut self) {
let active = self.gate.is_active();
if self.delivering && !active {
self.release_all();
}
self.delivering = active;
}
fn run(&mut self) {
let monitor = self.try_attach_monitor();
let inotify = self.try_attach_inotify();
self.open_all_devices();
let mut events = Events::new();
loop {
if self.stop.load(Ordering::Acquire) {
break;
}
events.clear();
if self.poller.wait(&mut events, None).is_err() {
if self
.poller
.wait(&mut events, self.next_retry_timeout())
.is_err()
{
break;
}
let mut device_fds_to_drain: Vec<RawFd> = Vec::new();
let mut drain_monitor = false;
let mut drain_inotify = false;
let mut got_exit = false;
for event in events.iter() {
match event.key {
EXIT_KEY => got_exit = true,
MONITOR_KEY => drain_monitor = true,
INOTIFY_KEY => drain_inotify = true,
fd_key => {
if let Some(fd) = device_fd_from_poll_key(fd_key) {
device_fds_to_drain.push(fd);
@@ -361,26 +503,43 @@ impl Reader {
break;
}
self.rearm(monitor.as_ref(), drain_monitor, &device_fds_to_drain);
self.rearm(
monitor.as_ref(),
drain_monitor,
inotify.as_ref().filter(|_| drain_inotify),
&device_fds_to_drain,
);
self.apply_session_gate();
if drain_monitor && let Some(monitor) = monitor.as_ref() {
self.drain_monitor(monitor);
}
if drain_inotify && let Some(inotify) = inotify.as_ref() {
self.drain_inotify(inotify);
}
self.run_due_retries();
for fd in device_fds_to_drain {
self.drain_device(fd);
}
}
if !self.stop.load(Ordering::Acquire) {
self.release_all();
}
if let Some(monitor) = monitor.as_ref() {
let borrowed = unsafe { BorrowedFd::borrow_raw(monitor.as_raw_fd()) };
let _ = self.poller.delete(borrowed);
}
if let Some(inotify) = inotify.as_ref() {
let _ = self.poller.delete(inotify.as_fd());
}
}
fn rearm(
&self,
monitor: Option<&udev::MonitorSocket>,
drain_monitor: bool,
inotify: Option<&Inotify>,
device_fds: &[RawFd],
) {
let _ = self
@@ -392,6 +551,11 @@ impl Reader {
.poller
.modify(borrowed, PollEvent::readable(MONITOR_KEY));
}
if let Some(inotify) = inotify {
let _ = self
.poller
.modify(inotify.as_fd(), PollEvent::readable(INOTIFY_KEY));
}
for fd in device_fds {
if self.devices.contains_key(fd) {
let Some(poll_key) = poll_key_for_device_fd(*fd) else {
@@ -428,13 +592,13 @@ impl Reader {
for (action, devnode, seat) in pending {
match action.as_str() {
"add" => {
if !self.seat.is_empty()
if !self.seats.seat.is_empty()
&& let Some(seat) = seat.as_deref()
&& seat != self.seat
&& seat != self.seats.seat
{
continue;
}
let _ = self.open_device(&devnode);
self.open_hotplugged_device(&devnode);
}
"remove" => self.close_device_by_path(&devnode),
_ => {}
@@ -442,22 +606,60 @@ impl Reader {
}
}
fn drain_inotify(&mut self, inotify: &Inotify) {
let mut paths: Vec<PathBuf> = Vec::new();
while let Ok(events) = inotify.read_events() {
if events.is_empty() {
break;
}
for event in events {
let Some(name) = event.name.as_deref().and_then(OsStr::to_str) else {
continue;
};
if !is_event_node_name(name) || !self.seats.includes(name) {
continue;
}
let path = Path::new(DEV_INPUT_DIR).join(name);
if !paths.contains(&path) {
paths.push(path);
}
}
}
for path in paths {
self.open_hotplugged_device(&path);
}
}
fn drain_device(&mut self, fd: RawFd) {
let mut decoded: Vec<(u16, i32)> = Vec::new();
let mut decoded: Vec<DeviceInput> = Vec::new();
let mut device_dead = false;
{
let Some(opened) = self.devices.get_mut(&fd) else {
return;
};
loop {
let fetch_result = opened.device.fetch_events();
match fetch_result {
match opened.device.fetch_events() {
Ok(events) => {
for ev in events {
if ev.event_type() != EventType::KEY {
continue;
match (ev.event_type(), ev.code()) {
(EventType::SYNCHRONIZATION, code)
if code == SynchronizationCode::SYN_DROPPED.0 =>
{
opened.dropped = true;
}
(EventType::SYNCHRONIZATION, code)
if opened.dropped
&& code == SynchronizationCode::SYN_REPORT.0 =>
{
opened.dropped = false;
decoded.push(DeviceInput::Resync);
}
_ if opened.dropped => {}
(EventType::KEY, code) => {
decoded.push(DeviceInput::Key(code, ev.value()));
}
_ => {}
}
decoded.push((ev.code(), ev.value()));
}
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => break,
@@ -472,50 +674,86 @@ impl Reader {
self.close_device_by_fd(fd);
return;
}
for (code, value) in decoded {
if value == 2 {
continue;
if !self.delivering {
return;
}
for input in decoded {
match input {
DeviceInput::Key(_, 2) => {}
DeviceInput::Key(code, value) => self.translate_event(fd, code, value == 1),
DeviceInput::Resync => self.resync_device(fd),
}
self.translate_event(code, value == 1);
}
}
fn translate_event(&mut self, code: u16, is_press: bool) {
fn resync_device(&mut self, fd: RawFd) {
let Some(opened) = self.devices.get_mut(&fd) else {
return;
};
let released = match opened.device.get_key_state() {
Ok(state) => {
let down: HashSet<u16> = state
.iter()
.map(|key| key.code())
.filter(|code| is_routable_key_code(*code))
.collect();
let released: HashSet<u16> = opened.held.difference(&down).copied().collect();
opened.held = down;
released
}
Err(_) => std::mem::take(&mut opened.held),
};
self.release_codes(released);
}
fn translate_event(&mut self, fd: RawFd, code: u16, is_press: bool) {
if !is_routable_key_code(code) {
return;
}
if let Some(opened) = self.devices.get_mut(&fd) {
if is_press {
opened.held.insert(code);
} else {
opened.held.remove(&code);
}
}
self.dispatch(self.native_event(code, is_press));
}
fn native_event(&self, code: u16, is_press: bool) -> NativeEvent {
let ctrl = self.modifier_state_ctrl();
let alt = self.modifier_state_alt();
let shift = self.modifier_state_shift();
let meta = self.modifier_state_meta();
if let Some(button) = keymap::evdev_button_to_browser_button(code) {
let event = NativeEvent::Mouse {
return NativeEvent::Mouse {
kind: if is_press {
MouseKind::Down
} else {
MouseKind::Up
},
button,
ctrl: self.modifier_state_ctrl(),
alt: self.modifier_state_alt(),
shift: self.modifier_state_shift(),
meta: self.modifier_state_meta(),
ctrl,
alt,
shift,
meta,
};
self.dispatch(event);
return;
}
let key_name = match keymap::keycode_to_name(code) {
Some(name) => name,
None => return,
};
if is_press {
self.held_keys.insert(code);
} else {
self.held_keys.remove(&code);
}
let event = NativeEvent::Key {
NativeEvent::Key {
kind: if is_press { KeyKind::Down } else { KeyKind::Up },
keycode: code,
key_name,
ctrl: self.modifier_state_ctrl(),
alt: self.modifier_state_alt(),
shift: self.modifier_state_shift(),
meta: self.modifier_state_meta(),
};
self.dispatch(event);
key_name: keymap::keycode_to_name(code).unwrap_or_default(),
ctrl,
alt,
shift,
meta,
}
}
fn is_held(&self, code: u16) -> bool {
self.devices
.values()
.any(|opened| opened.held.contains(&code))
}
fn dispatch(&self, event: NativeEvent) {
@@ -528,32 +766,53 @@ impl Reader {
}
fn modifier_state_ctrl(&self) -> bool {
self.held_keys.contains(&keymap::LEFT_CTRL) || self.held_keys.contains(&keymap::RIGHT_CTRL)
self.is_held(keymap::LEFT_CTRL) || self.is_held(keymap::RIGHT_CTRL)
}
fn modifier_state_alt(&self) -> bool {
self.held_keys.contains(&keymap::LEFT_ALT) || self.held_keys.contains(&keymap::RIGHT_ALT)
self.is_held(keymap::LEFT_ALT) || self.is_held(keymap::RIGHT_ALT)
}
fn modifier_state_shift(&self) -> bool {
self.held_keys.contains(&keymap::LEFT_SHIFT)
|| self.held_keys.contains(&keymap::RIGHT_SHIFT)
self.is_held(keymap::LEFT_SHIFT) || self.is_held(keymap::RIGHT_SHIFT)
}
fn modifier_state_meta(&self) -> bool {
self.held_keys.contains(&keymap::LEFT_META) || self.held_keys.contains(&keymap::RIGHT_META)
self.is_held(keymap::LEFT_META) || self.is_held(keymap::RIGHT_META)
}
}
const KEYBOARD_KEY_CODES: Range<u16> = KeyCode::KEY_ESC.code()..KeyCode::BTN_0.code();
const EXTENDED_KEY_CODES: Range<u16> = KeyCode::KEY_OK.code()..KeyCode::BTN_TRIGGER_HAPPY1.code();
fn is_routable_key_code(code: u16) -> bool {
keymap::keycode_to_name(code).is_some()
KEYBOARD_KEY_CODES.contains(&code)
|| EXTENDED_KEY_CODES.contains(&code)
|| keymap::evdev_button_to_browser_button(code).is_some()
}
fn device_has_routable_input(device: &Device) -> bool {
fn device_has_routable_input(device: &RawDevice) -> bool {
device.supported_keys().is_some_and(|keys| {
keys.iter()
.any(|key: KeyCode| is_routable_key_code(key.code()))
})
}
fn is_full_keyboard_key_set(supports: impl Fn(u16) -> bool) -> bool {
FULL_KEYBOARD_KEY_CODES.into_iter().all(supports)
}
fn device_is_full_keyboard(device: &RawDevice) -> bool {
device
.supported_keys()
.is_some_and(|keys| is_full_keyboard_key_set(|code| keys.contains(KeyCode::new(code))))
}
fn open_read_only(path: &Path) -> std::io::Result<RawDevice> {
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(OFlag::O_NONBLOCK.bits())
.open(path)?;
RawDevice::from_fd(OwnedFd::from(file))
}
impl Drop for Reader {
fn drop(&mut self) {
let fds: Vec<RawFd> = self.devices.keys().copied().collect();
@@ -570,11 +829,12 @@ struct HookInner {
stop: Option<Arc<AtomicBool>>,
exit_fd: Option<Arc<ExitFd>>,
thread: Option<JoinHandle<()>>,
opened: bool,
session_watch: Option<SessionWatch>,
}
impl HookInner {
fn stop_and_join(&mut self) {
self.session_watch = None;
if let Some(stop) = &self.stop {
stop.store(true, Ordering::Release);
}
@@ -586,7 +846,6 @@ impl HookInner {
}
self.stop = None;
self.exit_fd = None;
self.opened = false;
}
}
@@ -605,7 +864,6 @@ impl EvdevHook {
.build_threadsafe_function::<NativeEvent>()
.weak::<true>()
.callee_handled::<false>()
.max_queue_size::<EVENT_QUEUE_LIMIT>()
.build()
.map_err(|err| {
generic_error(format!("failed to build callback: {}", err.reason))
@@ -617,7 +875,7 @@ impl EvdevHook {
stop: None,
exit_fd: None,
thread: None,
opened: false,
session_watch: None,
}),
})
}
@@ -628,8 +886,11 @@ impl EvdevHook {
.inner
.lock()
.map_err(|_| generic_error("hook lock poisoned"))?;
if inner.thread.is_some() {
return Ok(inner.opened);
if let Some(thread) = inner.thread.as_ref() {
if !thread.is_finished() {
return Ok(true);
}
inner.stop_and_join();
}
let stop = Arc::new(AtomicBool::new(false));
let exit_fd = Arc::new(
@@ -640,8 +901,12 @@ impl EvdevHook {
.map_err(|err| generic_error(format!("evdev start failed: {err}")))?;
reader.open_all_devices();
let opened = !reader.devices.is_empty();
if !reader.has_full_keyboard() {
return Ok(false);
}
let session_watch = SessionWatch::spawn(reader.gate.clone())
.map_err(|err| generic_error(format!("failed to spawn logind thread: {err}")))?;
let join = thread::Builder::new()
.name("fluxer-linux-evdev-reader".to_string())
.spawn(move || {
@@ -653,8 +918,8 @@ impl EvdevHook {
inner.stop = Some(stop);
inner.exit_fd = Some(exit_fd);
inner.thread = Some(join);
inner.opened = opened;
Ok(opened)
inner.session_watch = Some(session_watch);
Ok(true)
}
#[napi]
@@ -676,9 +941,12 @@ impl Drop for EvdevHook {
}
}
#[napi(js_name = "nameToEvdevKeycode")]
pub fn name_to_evdev_keycode(name: String) -> u32 {
u32::from(keymap::name_to_keycode(&name))
#[napi(js_name = "isKeyboardReadable")]
pub fn is_keyboard_readable() -> bool {
SeatFilter::detect()
.event_node_paths()
.iter()
.any(|path| open_read_only(path).is_ok_and(|device| device_is_full_keyboard(&device)))
}
fn generic_error(reason: impl Into<String>) -> napi::Error {
@@ -693,11 +961,50 @@ mod tests {
fn control_poll_keys_are_legal_and_outside_fd_range() {
assert_eq!(EXIT_KEY, 0);
assert_eq!(MONITOR_KEY, 1);
assert_ne!(EXIT_KEY, MONITOR_KEY);
assert_eq!(INOTIFY_KEY, 2);
assert_eq!(poll_key_for_device_fd(0), Some(DEVICE_KEY_BASE));
assert_eq!(device_fd_from_poll_key(DEVICE_KEY_BASE), Some(0));
assert_eq!(device_fd_from_poll_key(EXIT_KEY), None);
assert_eq!(device_fd_from_poll_key(MONITOR_KEY), None);
assert_eq!(device_fd_from_poll_key(INOTIFY_KEY), None);
}
#[test]
fn full_keyboard_requires_every_key_from_escape_to_s() {
assert!(is_full_keyboard_key_set(|code| code < 200));
assert!(!is_full_keyboard_key_set(|code| code != 0 && code != 17));
assert!(!is_full_keyboard_key_set(|code| code == 0 || code >= 32));
assert!(is_full_keyboard_key_set(|code| (1..32).contains(&code)));
}
#[test]
fn event_node_names_match_only_event_devices() {
assert!(is_event_node_name("event0"));
assert!(is_event_node_name("event17"));
assert!(!is_event_node_name("mouse0"));
assert!(!is_event_node_name("js0"));
assert!(!is_event_node_name("by-id"));
}
#[test]
fn session_gate_notifies_only_on_change() {
let poller = Arc::new(Poller::new().expect("create poller"));
let gate = SessionGate::new(poller.clone());
assert!(!gate.is_active());
gate.set_active(false);
let mut events = Events::new();
poller
.wait(&mut events, Some(Duration::from_millis(0)))
.expect("poll without notification");
gate.set_active(true);
assert!(gate.is_active());
let started = Instant::now();
poller
.wait(&mut events, Some(Duration::from_secs(5)))
.expect("poll with notification");
assert!(started.elapsed() < Duration::from_secs(5));
gate.set_active(false);
assert!(!gate.is_active());
}
#[test]
@@ -722,6 +1029,37 @@ mod tests {
assert!(is_routable_key_code(KeyCode::BTN_FORWARD.code()));
}
#[test]
fn every_key_main_maps_to_a_dom_code_is_routable() {
let source = include_str!("../../../src/main/GlobalShortcutKeys.ts");
let table = source
.split("const EVDEV_KEY_CODES")
.nth(1)
.and_then(|rest| rest.split("]);").next())
.expect("EVDEV_KEY_CODES table");
let codes: Vec<u16> = table
.split("[0x")
.skip(1)
.map(|entry| {
let hex = entry.split(',').next().expect("code");
u16::from_str_radix(hex, 16).expect("hex code")
})
.collect();
assert!(codes.len() > 150);
for code in codes {
assert!(is_routable_key_code(code), "evdev code {code:#x}");
}
}
#[test]
fn routable_key_code_filter_keeps_keys_without_a_native_name() {
assert!(is_routable_key_code(KeyCode::KEY_CALC.code()));
assert!(is_routable_key_code(KeyCode::KEY_PLAYCD.code()));
assert!(is_routable_key_code(KeyCode::KEY_FN.code()));
assert!(!is_routable_key_code(0));
assert!(!is_routable_key_code(KeyCode::BTN_TRIGGER_HAPPY1.code()));
}
#[test]
fn routable_key_code_filter_ignores_tablet_pad_and_tool_buttons() {
assert!(!is_routable_key_code(KeyCode::BTN_0.code()));
@@ -0,0 +1,283 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
thread::{self, JoinHandle},
time::Duration,
};
use event_listener::Event;
use futures_lite::{StreamExt, future};
use polling::Poller;
use zbus::{Connection, Proxy, zvariant::OwnedObjectPath};
const LOGIND_DESTINATION: &str = "org.freedesktop.login1";
const LOGIND_PATH: &str = "/org/freedesktop/login1";
const MANAGER_INTERFACE: &str = "org.freedesktop.login1.Manager";
const SESSION_INTERFACE: &str = "org.freedesktop.login1.Session";
const USER_INTERFACE: &str = "org.freedesktop.login1.User";
const RETRY_DELAYS: [Duration; 5] = [
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(5),
Duration::from_secs(10),
Duration::from_secs(30),
];
pub struct SessionGate {
active: AtomicBool,
poller: Arc<Poller>,
}
impl SessionGate {
pub fn new(poller: Arc<Poller>) -> Self {
Self {
active: AtomicBool::new(false),
poller,
}
}
pub fn is_active(&self) -> bool {
self.active.load(Ordering::Acquire)
}
pub fn set_active(&self, active: bool) {
if self.active.swap(active, Ordering::AcqRel) != active {
let _ = self.poller.notify();
}
}
}
struct StopSignal {
stopped: AtomicBool,
event: Event,
}
impl StopSignal {
async fn wait(&self) {
loop {
if self.stopped.load(Ordering::Acquire) {
return;
}
let listener = self.event.listen();
if self.stopped.load(Ordering::Acquire) {
return;
}
listener.await;
}
}
}
pub struct SessionWatch {
stop: Arc<StopSignal>,
thread: Option<JoinHandle<()>>,
}
impl SessionWatch {
pub fn spawn(gate: Arc<SessionGate>) -> std::io::Result<Self> {
let stop = Arc::new(StopSignal {
stopped: AtomicBool::new(false),
event: Event::new(),
});
let thread_stop = stop.clone();
let thread = thread::Builder::new()
.name("fluxer-linux-evdev-logind".to_string())
.spawn(move || {
async_io::block_on(future::or(supervise(&gate), thread_stop.wait()));
})?;
Ok(Self {
stop,
thread: Some(thread),
})
}
fn shutdown(&mut self) {
self.stop.stopped.store(true, Ordering::Release);
self.stop.event.notify(usize::MAX);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
impl Drop for SessionWatch {
fn drop(&mut self) {
self.shutdown();
}
}
#[derive(Debug)]
enum WatchEnd {
Unreachable(zbus::Error),
NoSession(zbus::Error),
Lost(Option<zbus::Error>),
}
fn retry_delay(failures: usize) -> Duration {
RETRY_DELAYS[failures.min(RETRY_DELAYS.len() - 1)]
}
async fn supervise(gate: &SessionGate) {
let mut ever_subscribed = false;
let mut failures = 0usize;
loop {
let mut subscribed = false;
let end = watch_session(gate, &mut subscribed).await;
if subscribed {
ever_subscribed = true;
failures = 0;
}
match &end {
WatchEnd::Unreachable(err) if !ever_subscribed => {
eprintln!("[linux-evdev] logind unreachable, session gating disabled: {err}");
gate.set_active(true);
return;
}
WatchEnd::NoSession(err) if !ever_subscribed => {
eprintln!("[linux-evdev] no logind session found, session gating disabled: {err}");
gate.set_active(true);
return;
}
WatchEnd::Unreachable(err) | WatchEnd::NoSession(err) | WatchEnd::Lost(Some(err))
if failures == 0 =>
{
eprintln!("[linux-evdev] logind session watch lost, retrying: {err}");
}
_ => {}
}
async_io::Timer::after(retry_delay(failures)).await;
failures = failures.saturating_add(1);
}
}
fn session_id() -> Option<String> {
std::env::var("XDG_SESSION_ID")
.ok()
.filter(|value| !value.is_empty())
}
fn is_unreachable_error(err: &zbus::Error) -> bool {
match err {
zbus::Error::MethodError(name, _, _) => matches!(
name.as_str(),
"org.freedesktop.DBus.Error.ServiceUnknown"
| "org.freedesktop.DBus.Error.NameHasNoOwner"
| "org.freedesktop.DBus.Error.AccessDenied"
| "org.freedesktop.DBus.Error.Disconnected"
),
zbus::Error::InputOutput(_) | zbus::Error::Address(_) | zbus::Error::Handshake(_) => true,
_ => false,
}
}
fn classify(err: zbus::Error) -> WatchEnd {
if is_unreachable_error(&err) {
WatchEnd::Unreachable(err)
} else {
WatchEnd::NoSession(err)
}
}
fn is_session_path(path: &OwnedObjectPath) -> bool {
path.as_str() != "/"
}
async fn display_session(
connection: &Connection,
manager: &Proxy<'_>,
) -> zbus::Result<OwnedObjectPath> {
let user_path: OwnedObjectPath = manager.call("GetUserByPID", &(std::process::id(),)).await?;
let user = Proxy::new(connection, LOGIND_DESTINATION, user_path, USER_INTERFACE).await?;
let (id, path): (String, OwnedObjectPath) = user.get_property("Display").await?;
if id.is_empty() || !is_session_path(&path) {
return Err(zbus::Error::Failure(
"user has no display session".to_string(),
));
}
Ok(path)
}
async fn resolve_session(
connection: &Connection,
manager: &Proxy<'_>,
) -> Result<OwnedObjectPath, WatchEnd> {
if let Some(id) = session_id() {
match manager
.call::<_, _, OwnedObjectPath>("GetSession", &(id,))
.await
{
Ok(path) => return Ok(path),
Err(err) if is_unreachable_error(&err) => return Err(WatchEnd::Unreachable(err)),
Err(_) => {}
}
}
match manager
.call::<_, _, OwnedObjectPath>("GetSessionByPID", &(std::process::id(),))
.await
{
Ok(path) => return Ok(path),
Err(err) if is_unreachable_error(&err) => return Err(WatchEnd::Unreachable(err)),
Err(_) => {}
}
display_session(connection, manager).await.map_err(classify)
}
async fn watch_session(gate: &SessionGate, subscribed: &mut bool) -> WatchEnd {
let connection = match Connection::system().await {
Ok(connection) => connection,
Err(err) => return WatchEnd::Unreachable(err),
};
let manager = match Proxy::new(
&connection,
LOGIND_DESTINATION,
LOGIND_PATH,
MANAGER_INTERFACE,
)
.await
{
Ok(manager) => manager,
Err(err) => return classify(err),
};
let session_path = match resolve_session(&connection, &manager).await {
Ok(path) => path,
Err(end) => return end,
};
let session = match Proxy::new(
&connection,
LOGIND_DESTINATION,
session_path,
SESSION_INTERFACE,
)
.await
{
Ok(session) => session,
Err(err) => return classify(err),
};
let mut owner_changes = match session.receive_owner_changed().await {
Ok(stream) => stream,
Err(err) => return classify(err),
};
let mut changes = session.receive_property_changed::<bool>("Active").await;
match session.get_property::<bool>("Active").await {
Ok(active) => gate.set_active(active),
Err(err) => return classify(err),
}
*subscribed = true;
loop {
let change = future::or(async { changes.next().await }, async {
owner_changes.next().await;
None
})
.await;
let Some(change) = change else {
return WatchEnd::Lost(None);
};
match change.get().await {
Ok(active) => gate.set_active(active),
Err(err) => return WatchEnd::Lost(Some(err)),
}
}
}