diff --git a/Cargo.lock b/Cargo.lock index 3c92adc..1b76b78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4886,6 +4886,18 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reis" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b967ec6489a42067a20724f11987bb96178ea796873cbe1afd25fb93ee6f85f" +dependencies = [ + "calloop 0.14.4", + "enumflags2", + "log", + "rustix 1.1.5", +] + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -7415,6 +7427,7 @@ checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" name = "xdg-desktop-portal-ferese" version = "0.1.0" dependencies = [ + "calloop 0.14.4", "dirs", "ferese-config", "ferese-ipc", @@ -7429,6 +7442,7 @@ dependencies = [ "memmap2 0.9.11", "pipewire", "png 0.18.1", + "reis", "serde", "serde_json", "tempfile", diff --git a/compositor/src/handlers/activation.rs b/compositor/src/handlers/activation.rs index 3e46c87..f136316 100644 --- a/compositor/src/handlers/activation.rs +++ b/compositor/src/handlers/activation.rs @@ -27,7 +27,7 @@ impl XdgActivationHandler for Ferese { data: XdgActivationTokenData, surface: WlSurface, ) { - if self.session_lock.active { + if self.session_lock.active || self.input_capture.captures(1) { return; } let valid = data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE; diff --git a/compositor/src/handlers/layer_shell.rs b/compositor/src/handlers/layer_shell.rs index d03adb2..f603e79 100644 --- a/compositor/src/handlers/layer_shell.rs +++ b/compositor/src/handlers/layer_shell.rs @@ -108,6 +108,7 @@ pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) { let policy = layer.cached_state(); if !state.session_lock.active + && !state.input_capture.captures(1) && policy.keyboard_interactivity == KeyboardInteractivity::Exclusive && matches!(policy.layer, Layer::Top | Layer::Overlay) { diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index 27b889e..d52f371 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -254,7 +254,7 @@ impl XdgShellHandler for Ferese { } fn grab(&mut self, surface: PopupSurface, seat: wl_seat::WlSeat, serial: Serial) { - if self.session_lock.active { + if self.session_lock.active || self.input_capture.active() { return; } let Some(seat) = Seat::from_resource(&seat) else { diff --git a/compositor/src/input.rs b/compositor/src/input.rs index f95e2a8..14b9f89 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -59,6 +59,27 @@ impl Ferese { self.focus_lock_surface(); } + if self.input_capture.active() + && matches!( + &event, + InputEvent::GestureSwipeBegin { .. } + | InputEvent::GestureSwipeUpdate { .. } + | InputEvent::GestureSwipeEnd { .. } + | InputEvent::GesturePinchBegin { .. } + | InputEvent::GesturePinchUpdate { .. } + | InputEvent::GesturePinchEnd { .. } + | InputEvent::GestureHoldBegin { .. } + | InputEvent::GestureHoldEnd { .. } + | InputEvent::TouchDown { .. } + | InputEvent::TouchMotion { .. } + | InputEvent::TouchUp { .. } + | InputEvent::TouchCancel { .. } + | InputEvent::TouchFrame { .. } + ) + { + return; + } + match event { InputEvent::GestureSwipeBegin { event } => { let pointer = seat.get_pointer().expect("seat has a pointer"); @@ -143,6 +164,13 @@ impl Ferese { let keycode = event.key_code(); let state = event.state(); + let was_captured = self.input_capture.active(); + if self.input_capture.captures(1) { + keyboard.unset_grab(self); + if keyboard.current_focus().is_some() { + keyboard.set_focus(self, None, SERIAL_COUNTER.next_serial()); + } + } keyboard.input::<(), _>( self, keycode, @@ -160,6 +188,17 @@ impl Ferese { return FilterResult::Forward; } let symbol = keysym.modified_sym().raw(); + if data.input_capture.active() && emergency_shortcut_escape(symbol, modifiers.ctrl, modifiers.alt) { + if state == KeyState::Pressed { + data.input_capture.disable_all(); + data.intercepted_keys.insert(keycode); + } + return FilterResult::Intercept(()); + } + if data.input_capture.captures(1) { + data.input_capture.push(serde_json::json!({"type":"key", "key":keycode.raw().saturating_sub(8), "pressed":state == KeyState::Pressed})); + return FilterResult::Forward; + } if data.overview.is_active() && matches!(symbol, keysyms::KEY_Return | keysyms::KEY_KP_Enter) { @@ -249,12 +288,18 @@ impl Ferese { FilterResult::Intercept(()) }, ); + if was_captured && !self.input_capture.active() { + self.restore_input_capture_focus(); + } } InputEvent::PointerMotionAbsolute { event, .. } => { let Some(position) = self.absolute_event_position(&event) else { return; }; let pointer = self.seat.get_pointer().expect("seat has a pointer"); + if self.capture_pointer_motion(pointer.current_location(), position, true) { + return; + } let position = self.constrain_pointer_position(&pointer, position); pointer.motion( @@ -284,6 +329,9 @@ impl Ferese { }; let delta = event.delta(); let delta_unaccel = event.delta_unaccel(); + if self.capture_pointer_motion(position, position + delta, false) { + return; + } pointer.relative_motion( self, @@ -313,6 +361,12 @@ impl Ferese { crate::backends::direct::render_all(self); } InputEvent::PointerButton { event, .. } => { + if self.input_capture.active() { + if self.input_capture.captures(2) { + self.input_capture.push(serde_json::json!({"type":"button", "button":event.button_code(), "pressed":event.state() == ButtonState::Pressed})); + } + return; + } let pointer = self.seat.get_pointer().expect("seat has a pointer"); let serial = SERIAL_COUNTER.next_serial(); @@ -411,6 +465,12 @@ impl Ferese { let vertical = event.amount(Axis::Vertical).unwrap_or_else(|| { event.amount_v120(Axis::Vertical).unwrap_or(0.0) * 15.0 / 120.0 }); + if self.input_capture.active() { + if self.input_capture.captures(2) { + self.input_capture.push(serde_json::json!({"type":"scroll", "x":horizontal, "y":vertical, "v120_x":event.amount_v120(Axis::Horizontal), "v120_y":event.amount_v120(Axis::Vertical), "stop_x":source == AxisSource::Finger && event.amount(Axis::Horizontal) == Some(0.0), "stop_y":source == AxisSource::Finger && event.amount(Axis::Vertical) == Some(0.0)})); + } + return; + } let pointer = self.seat.get_pointer().expect("seat has a pointer"); if self.scroll_overview_strip( pointer.current_location(), @@ -664,6 +724,9 @@ impl Ferese { } fn focus_window_at(&mut self, position: Point, serial: Serial, raise: bool) { + if self.input_capture.captures(1) { + return; + } if self.session_lock.active { self.focus_output_at(position); self.focus_lock_surface(); diff --git a/compositor/src/input_capture.rs b/compositor/src/input_capture.rs new file mode 100644 index 0000000..8dfe4ed --- /dev/null +++ b/compositor/src/input_capture.rs @@ -0,0 +1,719 @@ +use std::{ + collections::{HashMap, VecDeque}, + sync::mpsc::SyncSender, +}; + +use ferese_ipc::Response; +use serde::Deserialize; +use serde_json::{Value, json}; +use smithay::wayland::keyboard_shortcuts_inhibit::KeyboardShortcutsInhibitorSeat; + +const MAX_SESSIONS: usize = 8; +const MAX_BARRIERS: usize = 64; +const MAX_EVENTS: usize = 512; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct Zone { + pub width: u32, + pub height: u32, + pub x: i32, + pub y: i32, +} + +impl Zone { + fn right(self) -> f64 { + f64::from(self.x) + f64::from(self.width) + } + + fn bottom(self) -> f64 { + f64::from(self.y) + f64::from(self.height) + } + + fn contains(self, point: (f64, f64)) -> bool { + point.0 >= f64::from(self.x) + && point.0 < self.right() + && point.1 >= f64::from(self.y) + && point.1 < self.bottom() + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Barrier { + pub id: u32, + pub position: [i32; 4], +} + +#[derive(Clone, Copy, Debug)] +enum Edge { + Left, + Right, + Top, + Bottom, +} + +fn edge(barrier: &Barrier, zones: &[Zone]) -> Option { + if barrier.id == 0 { + return None; + } + let [x1, y1, x2, y2] = barrier.position.map(f64::from); + let horizontal = y1 == y2 && x1 <= x2; + let vertical = x1 == x2 && y1 <= y2; + if !horizontal && !vertical { + return None; + } + for zone in zones { + let found = if horizontal && x1 >= f64::from(zone.x) && x2 < zone.right() { + if y1 == f64::from(zone.y) { + Some(Edge::Top) + } else if y1 == zone.bottom() { + Some(Edge::Bottom) + } else { + None + } + } else if vertical && y1 >= f64::from(zone.y) && y2 < zone.bottom() { + if x1 == f64::from(zone.x) { + Some(Edge::Left) + } else if x1 == zone.right() { + Some(Edge::Right) + } else { + None + } + } else { + None + }; + let Some(found) = found else { + continue; + }; + let intersects_neighbor = zones.iter().any(|other| { + if other == zone { + return false; + } + match found { + Edge::Top | Edge::Bottom => { + let outside = y1 + + if matches!(found, Edge::Top) { + -0.5 + } else { + 0.5 + }; + outside >= f64::from(other.y) + && outside < other.bottom() + && x1 < other.right() + && x2 + 1.0 > f64::from(other.x) + } + Edge::Left | Edge::Right => { + let outside = x1 + + if matches!(found, Edge::Left) { + -0.5 + } else { + 0.5 + }; + outside >= f64::from(other.x) + && outside < other.right() + && y1 < other.bottom() + && y2 + 1.0 > f64::from(other.y) + } + } + }); + if !intersects_neighbor { + return Some(found); + } + } + None +} + +fn crossed(barrier: &Barrier, edge: Edge, from: (f64, f64), to: (f64, f64)) -> Option { + let [x1, y1, x2, y2] = barrier.position.map(f64::from); + let (distance, movement, start, end) = match edge { + Edge::Left if from.0 >= x1 && to.0 < x1 => (x1 - from.0, to.0 - from.0, y1, y2 + 1.0), + Edge::Right if from.0 < x1 && to.0 >= x1 => (x1 - from.0, to.0 - from.0, y1, y2 + 1.0), + Edge::Top if from.1 >= y1 && to.1 < y1 => (y1 - from.1, to.1 - from.1, x1, x2 + 1.0), + Edge::Bottom if from.1 < y1 && to.1 >= y1 => (y1 - from.1, to.1 - from.1, x1, x2 + 1.0), + _ => return None, + }; + let fraction = distance / movement; + let along = if matches!(edge, Edge::Left | Edge::Right) { + from.1 + (to.1 - from.1) * fraction + } else { + from.0 + (to.0 - from.0) * fraction + }; + (fraction >= 0.0 && fraction <= 1.0 && along >= start && along < end).then_some(fraction) +} + +struct Session { + owner: u64, + capabilities: u32, + barriers: Vec<(Barrier, Edge)>, + enabled: bool, + closed: bool, + activation: Option, + events: VecDeque, +} + +#[derive(Default)] +pub(crate) struct InputCapture { + sessions: HashMap, + zones: Vec, + zone_set: u32, + next_session: u64, + next_activation: u32, + active: Option, + last_absolute: Option<(f64, f64)>, + pub(crate) restore_focus: bool, + waiters: HashMap)>, +} + +impl InputCapture { + pub(crate) fn update_zones(&mut self, mut zones: Vec) { + zones.sort_by_key(|zone| (zone.x, zone.y, zone.width, zone.height)); + zones.dedup(); + if self.zones == zones { + return; + } + self.zones = zones; + self.zone_set = self.zone_set.wrapping_add(1); + self.disable_all(); + let ids = self.sessions.keys().copied().collect::>(); + for id in ids { + self.sessions.get_mut(&id).unwrap().barriers.clear(); + self.enqueue(id, json!({"type":"zones", "zone_set":self.zone_set})); + } + self.flush(); + } + + pub(crate) fn register(&mut self, owner: u64, capabilities: u32) -> Result { + if self.sessions.len() >= MAX_SESSIONS || capabilities == 0 || capabilities & !3 != 0 { + return Err("Unsupported capabilities or input capture session limit reached".into()); + } + self.next_session = self + .next_session + .checked_add(1) + .ok_or("Input capture IDs exhausted")?; + let id = self.next_session; + self.sessions.insert( + id, + Session { + owner, + capabilities, + barriers: Vec::new(), + enabled: false, + closed: false, + activation: None, + events: VecDeque::new(), + }, + ); + Ok(id) + } + + pub(crate) fn zones(&self, id: u64) -> Result { + self.session(id)?; + Ok( + json!({"zone_set":self.zone_set, "zones":self.zones.iter().map(|zone| + (zone.width, zone.height, zone.x, zone.y)).collect::>()}), + ) + } + + fn session(&self, id: u64) -> Result<&Session, String> { + self.sessions + .get(&id) + .ok_or("Unknown input capture session".into()) + } + + pub(crate) fn set_barriers( + &mut self, + id: u64, + zone_set: u32, + barriers: Vec, + ) -> Result, String> { + self.session(id)?; + if barriers.len() > MAX_BARRIERS { + return Err("Too many pointer barriers".into()); + } + self.disable(id)?; + let mut accepted = Vec::new(); + let mut failed = Vec::new(); + let mut counts = HashMap::new(); + for barrier in &barriers { + *counts.entry(barrier.id).or_insert(0) += 1; + } + for barrier in barriers { + let kind = edge(&barrier, &self.zones); + if zone_set != self.zone_set || counts[&barrier.id] != 1 || kind.is_none() { + failed.push(barrier.id); + } else { + accepted.push((barrier, kind.unwrap())); + } + } + self.sessions.get_mut(&id).unwrap().barriers = accepted; + Ok(failed) + } + + pub(crate) fn enable(&mut self, id: u64) -> Result<(), String> { + let session = self + .sessions + .get_mut(&id) + .ok_or("Unknown input capture session")?; + if session.closed { + return Err("Input capture session is closed".into()); + } + session.enabled = true; + Ok(()) + } + + pub(crate) fn has_sessions(&self) -> bool { + !self.sessions.is_empty() + } + + pub(crate) fn active(&self) -> bool { + self.active.is_some() + } + + pub(crate) fn captures(&self, capability: u32) -> bool { + self.active + .and_then(|id| self.sessions.get(&id)) + .is_some_and(|session| session.capabilities & capability != 0) + } + + pub(crate) fn motion(&mut self, from: (f64, f64), to: (f64, f64), allowed: bool) -> bool { + if self.active() { + return true; + } + if !allowed + || ![from.0, from.1, to.0, to.1] + .iter() + .all(|value| value.is_finite()) + { + return false; + } + let hit = self + .sessions + .iter() + .filter(|(_, session)| session.enabled && !session.closed) + .flat_map(|(id, session)| { + session.barriers.iter().filter_map(move |(barrier, kind)| { + crossed(barrier, *kind, from, to).map(|fraction| (*id, barrier.id, fraction)) + }) + }) + .min_by(|left, right| left.2.total_cmp(&right.2).then(left.0.cmp(&right.0))); + let Some((id, barrier, _)) = hit else { + return false; + }; + self.next_activation = self.next_activation.wrapping_add(1); + let activation = self.next_activation; + self.active = Some(id); + let session = self.sessions.get_mut(&id).unwrap(); + session.activation = Some(activation); + self.enqueue( + id, + json!({"type":"activated", "activation_id":activation, + "barrier_id":barrier, "cursor_position":to}), + ); + self.flush(); + true + } + + fn enqueue(&mut self, id: u64, event: Value) { + let Some(session) = self.sessions.get_mut(&id) else { + return; + }; + if session.closed { + return; + } + if session.events.len() >= MAX_EVENTS { + session.events.clear(); + session.events.push_back(json!({"type":"closed"})); + session.closed = true; + session.enabled = false; + session.activation = None; + if self.active == Some(id) { + self.active = None; + self.last_absolute = None; + self.restore_focus = true; + } + return; + } + session.events.push_back(event); + } + + pub(crate) fn push(&mut self, event: Value) { + if let Some(id) = self.active { + self.enqueue(id, event); + self.flush(); + } + } + + pub(crate) fn absolute_delta(&mut self, point: (f64, f64)) -> (f64, f64) { + let previous = self.last_absolute.replace(point).unwrap_or(point); + (point.0 - previous.0, point.1 - previous.1) + } + + pub(crate) fn close_all(&mut self) { + if self.active.take().is_some() { + self.restore_focus = true; + } + self.last_absolute = None; + self.sessions.clear(); + self.flush(); + } + + pub(crate) fn release(&mut self, id: u64, activation: Option) -> Result<(), String> { + let session = self.session(id)?; + if activation.is_some() && session.activation != activation { + return Err("Stale input capture activation".into()); + } + if self.active == Some(id) { + self.active = None; + self.last_absolute = None; + self.restore_focus = true; + let session = self.sessions.get_mut(&id).unwrap(); + let activation = session.activation.take(); + self.enqueue( + id, + json!({"type":"deactivated", "activation_id":activation}), + ); + self.flush(); + } + Ok(()) + } + + pub(crate) fn disable(&mut self, id: u64) -> Result<(), String> { + self.release(id, None)?; + let session = self.sessions.get_mut(&id).unwrap(); + if session.enabled { + session.enabled = false; + self.enqueue(id, json!({"type":"disabled"})); + } + self.flush(); + Ok(()) + } + + pub(crate) fn disable_all(&mut self) { + let ids = self.sessions.keys().copied().collect::>(); + for id in ids { + let _ = self.disable(id); + } + } + + pub(crate) fn valid_position(&self, point: (f64, f64)) -> bool { + point.0.is_finite() + && point.1.is_finite() + && self.zones.iter().any(|zone| zone.contains(point)) + } + + pub(crate) fn watch( + &mut self, + owner: u64, + request: u64, + id: u64, + response: SyncSender, + ) { + if !self.sessions.contains_key(&id) + || self.waiters.len() >= MAX_SESSIONS + || self.waiters.values().any(|(_, token, _)| *token == id) + { + let _ = response.try_send(Response::error( + request, + "invalid_capture", + "Unknown or already watched input capture session", + )); + return; + } + self.waiters.insert(owner, (request, id, response)); + self.flush(); + } + + fn flush(&mut self) { + let ready = self + .waiters + .iter() + .filter_map(|(owner, (_, id, _))| { + self.sessions + .get(id) + .is_none_or(|session| !session.events.is_empty()) + .then_some(*owner) + }) + .collect::>(); + for owner in ready { + let (request, id, response) = self.waiters.remove(&owner).unwrap(); + let result = if let Some(session) = self.sessions.get_mut(&id) { + json!({"events":session.events.drain(..).collect::>()}) + } else { + json!({"events":[{"type":"closed"}]}) + }; + let _ = response.try_send(Response::success(request, result)); + } + } + + pub(crate) fn remove_owner(&mut self, owner: u64) { + self.waiters.remove(&owner); + let ids = self + .sessions + .iter() + .filter_map(|(id, session)| (session.owner == owner).then_some(*id)) + .collect::>(); + for id in ids { + let _ = self.release(id, None); + self.sessions.remove(&id); + } + self.flush(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn zones() -> Vec { + vec![ + Zone { + width: 1920, + height: 1080, + x: 0, + y: 0, + }, + Zone { + width: 1920, + height: 1080, + x: 1920, + y: 0, + }, + ] + } + + #[test] + fn barriers_use_inclusive_pixel_span_and_only_outer_union_edges() { + let zones = zones(); + for position in [ + [0, 0, 1919, 0], + [0, 1080, 1919, 1080], + [1920, 0, 3839, 0], + [3840, 0, 3840, 1079], + [0, 0, 0, 1079], + ] { + assert!( + edge(&Barrier { id: 1, position }, &zones).is_some(), + "{position:?}" + ); + } + for position in [ + [1920, 0, 1920, 1079], + [0, 0, 1920, 0], + [5, 10, 8, 13], + [50, 50, 50, 200], + [0, 5, 0, 4], + ] { + assert!( + edge(&Barrier { id: 1, position }, &zones).is_none(), + "{position:?}" + ); + } + let barrier = Barrier { + id: 1, + position: [3840, 0, 3840, 1079], + }; + assert!(crossed(&barrier, Edge::Right, (3830.0, 1079.5), (3900.0, 1079.5)).is_some()); + assert!(crossed(&barrier, Edge::Right, (3830.0, 1080.0), (3900.0, 1080.0)).is_none()); + } + + #[test] + fn absolute_samples_report_incremental_motion_and_reset_on_release() { + let mut capture = InputCapture::default(); + assert_eq!(capture.absolute_delta((100.0, 50.0)), (0.0, 0.0)); + assert_eq!(capture.absolute_delta((110.0, 55.0)), (10.0, 5.0)); + assert_eq!(capture.absolute_delta((120.0, 60.0)), (10.0, 5.0)); + capture.close_all(); + assert_eq!(capture.absolute_delta((600.0, 700.0)), (0.0, 0.0)); + } + + #[test] + fn duplicate_ids_are_rejected_without_installing_a_barrier() { + let mut capture = InputCapture::default(); + capture.update_zones(zones()); + let id = capture.register(1, 3).unwrap(); + let barrier = Barrier { + id: 9, + position: [3840, 0, 3840, 1079], + }; + let failed = capture + .set_barriers(id, capture.zone_set, vec![barrier.clone(), barrier]) + .unwrap(); + assert_eq!(failed, vec![9, 9]); + assert!(capture.sessions[&id].barriers.is_empty()); + } + + #[test] + fn stalled_control_and_input_queues_close_and_restore_local_focus() { + let mut capture = InputCapture::default(); + capture.update_zones(zones()); + let id = capture.register(1, 3).unwrap(); + capture + .set_barriers( + id, + capture.zone_set, + vec![Barrier { + id: 1, + position: [3840, 0, 3840, 1079], + }], + ) + .unwrap(); + capture.enable(id).unwrap(); + assert!(capture.motion((3839.0, 50.0), (3900.0, 50.0), true)); + for _ in 0..MAX_EVENTS + 10 { + capture.push(json!({"type":"motion", "x":1.0, "y":0.0})); + } + assert!(!capture.active()); + assert!(capture.restore_focus); + assert!(capture.enable(id).is_err()); + assert_eq!(capture.sessions[&id].events.len(), 1); + assert_eq!(capture.sessions[&id].events[0]["type"], "closed"); + + let id = capture.register(2, 2).unwrap(); + for _ in 0..MAX_EVENTS + 10 { + capture.enable(id).ok(); + capture.disable(id).unwrap(); + } + assert_eq!(capture.sessions[&id].events.len(), 1); + assert!(capture.sessions[&id].closed); + } + + #[test] + fn release_preserves_enable_but_topology_changes_disable_and_invalidate() { + let mut capture = InputCapture::default(); + capture.update_zones(zones()); + let id = capture.register(9, 3).unwrap(); + let barrier = Barrier { + id: 1, + position: [3840, 0, 3840, 1079], + }; + assert!( + capture + .set_barriers(id, capture.zone_set, vec![barrier]) + .unwrap() + .is_empty() + ); + capture.enable(id).unwrap(); + assert!(capture.motion((3839.0, 50.0), (3900.0, 50.0), true)); + let activation = capture.sessions[&id].activation.unwrap(); + assert!( + capture + .release(id, Some(activation.wrapping_add(1))) + .is_err() + ); + capture.release(id, Some(activation)).unwrap(); + assert!(capture.sessions[&id].enabled); + capture.update_zones(vec![zones()[0]]); + assert!(!capture.active()); + assert!(!capture.sessions[&id].enabled); + assert!(capture.sessions[&id].barriers.is_empty()); + } +} + +impl crate::Ferese { + pub(crate) fn refresh_input_capture_zones(&mut self) { + if !self.input_capture.has_sessions() { + return; + } + let active = self.input_capture.active(); + let zones = self + .space + .outputs() + .filter_map(|output| { + let rect = self.space.output_geometry(output)?; + Some(Zone { + width: u32::try_from(rect.size.w).ok()?, + height: u32::try_from(rect.size.h).ok()?, + x: rect.loc.x, + y: rect.loc.y, + }) + }) + .collect(); + self.input_capture.update_zones(zones); + if active && !self.input_capture.active() { + self.restore_input_capture_focus(); + } + } + + pub(crate) fn restore_input_capture_focus(&mut self) { + self.input_capture.restore_focus = false; + self.cursor_redraw_pending = true; + if !self.session_lock.active { + self.restore_keyboard_focus(); + if let Some(pointer) = self.seat.get_pointer() { + let location = pointer.current_location(); + pointer.motion( + self, + self.surface_under(location), + &smithay::input::pointer::MotionEvent { + location, + serial: smithay::utils::SERIAL_COUNTER.next_serial(), + time: 0, + }, + ); + pointer.frame(self); + } + } + } + + pub(crate) fn capture_pointer_motion( + &mut self, + from: smithay::utils::Point, + to: smithay::utils::Point, + absolute: bool, + ) -> bool { + let pointer = self.seat.get_pointer().expect("seat has pointer"); + let active = self.input_capture.active(); + let allowed = !self.session_lock.active + && !self.overview.is_presenting() + && !pointer.is_grabbed() + && !self.swipe.active() + && self + .seat + .get_keyboard() + .is_none_or(|keyboard| !keyboard.is_grabbed()) + && self + .seat + .get_touch() + .is_none_or(|touch| !touch.is_grabbed()) + && !self.seat.keyboard_shortcuts_inhibited(); + if !self + .input_capture + .motion((from.x, from.y), (to.x, to.y), allowed) + { + return false; + } + let delta = if absolute { + self.input_capture.absolute_delta((to.x, to.y)) + } else { + (to.x - from.x, to.y - from.y) + }; + if !active { + self.cursor_redraw_pending = true; + pointer.motion( + self, + None, + &smithay::input::pointer::MotionEvent { + location: from, + serial: smithay::utils::SERIAL_COUNTER.next_serial(), + time: 0, + }, + ); + pointer.frame(self); + if self.input_capture.captures(1) { + let keyboard = self.seat.get_keyboard().expect("seat has keyboard"); + let keys = keyboard + .pressed_keys() + .iter() + .filter(|key| !self.intercepted_keys.contains(key)) + .map(|key| key.raw().saturating_sub(8)) + .collect::>(); + keyboard.set_focus(self, None, smithay::utils::SERIAL_COUNTER.next_serial()); + self.input_capture.push(json!({"type":"keys", "keys":keys})); + } + } else if self.input_capture.captures(2) { + self.input_capture + .push(json!({"type":"motion", "x":delta.0, "y":delta.1})); + } + true + } +} diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 5c71b98..2b97f62 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -47,6 +47,7 @@ const DEADLINE_IDLE: Duration = Duration::from_secs(1); #[derive(Debug)] struct IpcCall { + native_portal: bool, owner: u64, request: Request, response: SyncSender, @@ -121,13 +122,42 @@ pub(crate) fn init( let call = match event { IpcEvent::Call(call) => call, IpcEvent::Closed(owner) => { + let captured = state.input_capture.active(); + state.input_capture.remove_owner(owner); + if captured && !state.input_capture.active() { + state.restore_input_capture_focus(); + } state.portal_shortcuts.remove(owner); state.portal_session.remove(owner); state.refresh_idle_inhibition(); return; } }; - if call.request.command == "session-watch" { + if call.request.command.starts_with("input-capture-") && !call.native_portal { + let _ = call.response.try_send(Response::error( + call.request.id, + "access_denied", + "Input capture is restricted to the native portal", + )); + } else if call.request.command == "input-capture-watch" { + if let Err(error) = validate_request(&call.request) { + let _ = call.response.try_send(Response::error( + call.request.id, + error.code, + error.message, + )); + } else if let Some(id) = call.request.args["session"].as_u64() { + state + .input_capture + .watch(call.owner, call.request.id, id, call.response); + } else { + let _ = call.response.try_send(Response::error( + call.request.id, + "invalid_argument", + "Missing capture session", + )); + } + } else if call.request.command == "session-watch" { if let Err(error) = validate_request(&call.request) { let _ = call.response.send(Response::error( call.request.id, @@ -310,6 +340,7 @@ fn serve_connection( signal: LoopSignal, owner: u64, ) { + let native_portal = crate::handlers::window_capture::is_portal(&stream); let _connection = IpcConnection { owner, sender: sender.clone(), @@ -337,6 +368,7 @@ fn serve_connection( let screenshot_requested = matches!(request.command.as_str(), "screenshot" | "screenshot-window"); let call = IpcEvent::Call(IpcCall { + native_portal, owner, request, response, @@ -417,6 +449,8 @@ impl Ferese { && !matches!( request.command.as_str(), "portal-shortcuts-poll" + | "input-capture-disable" + | "input-capture-release" | "get-session-state" | "portal-inhibit" | "portal-monitor-register" @@ -525,6 +559,106 @@ impl Ferese { "portal-shortcuts-poll" => { return Ok(self.portal_shortcuts.poll(owner, self.session_lock.active)); } + "input-capture-register" => { + if self.session_lock.active { + return Err(CommandError::new("session_locked", "Session is locked")); + } + let id = self + .input_capture + .register(owner, u32_arg(args, "capabilities")?) + .map_err(|e| CommandError::new("invalid_capture", e))?; + self.refresh_input_capture_zones(); + let keyboard = self.seat.get_keyboard().expect("seat has keyboard"); + let keymap = keyboard.with_xkb_state(self, |state| { + let xkb = state.xkb().lock().unwrap(); + // SAFETY: the ref-counted keymap does not escape the locked Xkb; + // only its owned string is retained for the EIS keyboard. + unsafe { xkb.keymap() } + .get_as_string(smithay::input::keyboard::xkb::KEYMAP_FORMAT_TEXT_V1) + }); + return Ok(json!({"session":id, "keymap":keymap})); + } + "input-capture-zones" => { + self.refresh_input_capture_zones(); + return self + .input_capture + .zones( + args["session"].as_u64().ok_or_else(|| { + CommandError::new("invalid_capture", "Missing session") + })?, + ) + .map_err(|e| CommandError::new("invalid_capture", e)); + } + "input-capture-barriers" => { + self.refresh_input_capture_zones(); + let id = args["session"] + .as_u64() + .ok_or_else(|| CommandError::new("invalid_capture", "Missing session"))?; + let barriers = serde_json::from_value(args["barriers"].clone()) + .map_err(|_| CommandError::new("invalid_capture", "Invalid barriers"))?; + let active = self.input_capture.active(); + let failed = self + .input_capture + .set_barriers(id, u32_arg(args, "zone_set")?, barriers) + .map_err(|e| CommandError::new("invalid_capture", e))?; + if active && !self.input_capture.active() { + self.restore_input_capture_focus(); + } + return Ok(json!({"failed_barriers":failed})); + } + "input-capture-enable" => { + self.refresh_input_capture_zones(); + let id = args["session"] + .as_u64() + .ok_or_else(|| CommandError::new("invalid_capture", "Missing session"))?; + self.input_capture + .enable(id) + .map_err(|e| CommandError::new("invalid_capture", e))?; + } + "input-capture-disable" | "input-capture-release" => { + let id = args["session"] + .as_u64() + .ok_or_else(|| CommandError::new("invalid_capture", "Missing session"))?; + let position = args + .get("cursor_position") + .filter(|position| !position.is_null()) + .map(|position| { + let position = position + .as_array() + .filter(|values| values.len() == 2) + .ok_or("Invalid cursor position")?; + let point = ( + position[0].as_f64().ok_or("Invalid cursor position")?, + position[1].as_f64().ok_or("Invalid cursor position")?, + ); + self.input_capture + .valid_position(point) + .then_some(point) + .ok_or("Cursor position is outside the capture zones") + }) + .transpose() + .map_err(|e| CommandError::new("invalid_capture", e))?; + let active = self.input_capture.active(); + let result = if command == "input-capture-disable" { + self.input_capture.disable(id) + } else { + self.input_capture.release( + id, + args["activation_id"] + .as_u64() + .and_then(|id| u32::try_from(id).ok()), + ) + }; + result.map_err(|e| CommandError::new("invalid_capture", e))?; + if active && !self.input_capture.active() { + if let Some(position) = position + && let Some(pointer) = self.seat.get_pointer() + { + pointer.set_location(position.into()); + } + self.restore_input_capture_focus(); + } + } "exit" => {} // The IPC worker stops the loop after writing the response. "request-logout" => self.request_logout_confirmation(), "reload-config" => self diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 38a8734..4574bb2 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -10,6 +10,7 @@ mod gestures; mod grabs; mod handlers; mod input; +mod input_capture; mod ipc; mod metrics; mod overview; @@ -137,6 +138,9 @@ fn main() -> Result<(), Box> { } // All input/Wayland callbacks have returned, releasing seat locks. // Coalesce cursor changes and redraw here, never inside cursor_image. + if state.input_capture.restore_focus { + state.restore_input_capture_focus(); + } let wallpaper_retry = state.wallpaper.take_retry_wakeup(); if std::mem::take(&mut state.cursor_redraw_pending) || wallpaper_changed || wallpaper_retry { diff --git a/compositor/src/session_lock.rs b/compositor/src/session_lock.rs index 6690257..463530d 100644 --- a/compositor/src/session_lock.rs +++ b/compositor/src/session_lock.rs @@ -123,6 +123,7 @@ impl SessionLockHandler for Ferese { } self.cancel_logout_confirmation(); self.session_lock.active = true; + self.input_capture.disable_all(); self.portal_session.set_locked(true); self.session_lock.owner = Some(confirmation.ext_session_lock().clone()); self.session_lock.confirmation = Some(confirmation); diff --git a/compositor/src/state.rs b/compositor/src/state.rs index eda2937..22f663d 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -232,6 +232,7 @@ pub struct Ferese { pub display_handle: DisplayHandle, pub loop_signal: LoopSignal, pub(crate) loop_handle: smithay::reexports::calloop::LoopHandle<'static, Self>, + pub(crate) input_capture: crate::input_capture::InputCapture, pub(crate) portal_session: crate::portal_session::PortalSession, pub space: Space, pub workspaces: WorkspaceSet, @@ -453,6 +454,7 @@ impl Ferese { loop_signal: event_loop.get_signal(), loop_handle: event_loop.handle(), portal_session: Default::default(), + input_capture: Default::default(), space: Space::default(), workspaces: WorkspaceSet::new( config.layout_mode, @@ -582,6 +584,10 @@ impl Ferese { if let Some(keyboard) = self.seat.get_keyboard() { if keyboard_changed { + self.input_capture.close_all(); + if self.input_capture.restore_focus { + self.restore_input_capture_focus(); + } let options = (!config.input_settings.xkb_options.is_empty()) .then(|| config.input_settings.xkb_options.join(",")); @@ -896,6 +902,9 @@ impl Ferese { if self.session_lock.active { return self.lock_surface_under(position); } + if self.input_capture.active() { + return None; + } self.layer_surface_under(position, &[Layer::Overlay, Layer::Top]) .map(|(_, surface, origin)| (surface, origin)) .or_else(|| self.window_surface_under(position)) @@ -1594,6 +1603,7 @@ impl Ferese { } pub fn relayout(&mut self) { + self.refresh_input_capture_zones(); let visible_workspaces = self.visible_workspace_ids(); self.prune_empty_workspaces(&visible_workspaces); if self.session_lock.active { @@ -2428,6 +2438,9 @@ impl Ferese { } fn focus_direction_with_slide(&mut self, direction: Direction, slide: bool) { + if self.input_capture.captures(1) { + return; + } if self.focus_overview_direction(direction) { return; } @@ -3007,6 +3020,9 @@ impl Ferese { } pub(crate) fn restore_keyboard_focus(&mut self) { + if self.input_capture.captures(1) { + return; + } if self.session_lock.active { self.focus_lock_surface(); return; diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 6878c30..cc93bbb 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -2863,6 +2863,9 @@ fn cursor_elements( output_geometry: Rectangle, scale: f64, ) -> Vec { + if state.input_capture.active() { + return Vec::new(); + } let Some(pointer) = state.seat.get_pointer() else { return Vec::new(); }; diff --git a/docs/portals.md b/docs/portals.md index 09cc3e0..227c3e8 100644 --- a/docs/portals.md +++ b/docs/portals.md @@ -47,13 +47,20 @@ standard file, print and application dialogs. Install both backends. for acknowledgements for up to one second. Power confirmations list blockers and recheck them before acting; authentication failure cancels the query. UserSwitch is recorded, but Ferese currently has no user-switch operation. +- **InputCapture:** approved keyboard and pointer capture over a private EIS + connection, with standard screen zones and pointer barriers on outside display + edges. Capture begins at a configured barrier; releasing it restores local + input. Ctrl+Alt+Escape returns control immediately. Locking, topology/keymap + changes, receiver disconnects and stalled consumers stop capture. Permission + is requested for every session; touch capture and remembered grants are not + offered. This is an input receiver, not remote input injection. - **ScreenCast:** monitor and isolated window sharing with a native picker, PipeWire streams and explicit opt-in saved permissions. The frontend stores and revokes saved choices. Invalid restore data or changed display identity requires fresh consent. See [screen sharing](screen-sharing.md) for lifetime and recording details. Native request dialogs use Ferese's theme and are floating windows. Screenshot, -PickColor, Wallpaper, USB and ScreenCast consent dialogs use Wayland parent identifiers to establish +PickColor, Wallpaper, USB, InputCapture and ScreenCast consent dialogs use Wayland parent identifiers to establish a transient relationship with the requesting window. Cancelling a request or losing the portal frontend terminates its pending helper. @@ -80,7 +87,7 @@ Matching D-Bus signatures does not establish complete feature parity. | GlobalShortcuts | Native sessions, configurable keyboard triggers, conflict checks and activation/release signals | | RemoteDesktop | Deferred: authorized input injection and EIS transport | | Clipboard | Deferred with RemoteDesktop | -| InputCapture | Missing zones, pointer barriers and EIS transport | +| InputCapture | Native v2 keyboard/pointer consent, zones, outside-edge barriers and private EIS receiver transport | | Lockdown | Native seven-property policy provider with persisted configuration and change signals | | Inhibit | Native inhibitors and session monitors; UserSwitch recorded until a switch operation exists | @@ -95,8 +102,7 @@ only to the native portal executable and checks session opt-in and locking. Window resizing renegotiates PipeWire video dimensions. Closing the selected window ends sharing; its lifetime ID cannot select a replacement app. Window selections require fresh consent and are not saved as display permissions. -InputCapture still needs native zones, barriers and EIS transport. RemoteDesktop -and its Clipboard integration remain deferred. +RemoteDesktop and its Clipboard integration remain deferred. ## Validation @@ -155,3 +161,14 @@ closure using a disposable nested compositor and a real PipeWire consumer: ```sh FERESE_TEST_WINDOW_STREAM=1 python3 scripts/tests/test_window_stream_isolated.py ``` + +Check InputCapture state and its transport against an installed libei receiver: + +```sh +cargo test --locked -p ferese input_capture::tests +cargo test --locked -p xdg-desktop-portal-ferese eis::tests -- --ignored +``` + +The receiver test uses a private socket pair and synthetic input; it never +captures the host keyboard or changes host focus. Real pointer-barrier crossings +and interactive consent still need a live session check. diff --git a/packaging/portal/ferese-portals.conf b/packaging/portal/ferese-portals.conf index ddd343d..bd7b159 100644 --- a/packaging/portal/ferese-portals.conf +++ b/packaging/portal/ferese-portals.conf @@ -9,3 +9,4 @@ org.freedesktop.impl.portal.Usb=ferese; org.freedesktop.impl.portal.Lockdown=ferese; org.freedesktop.impl.portal.GlobalShortcuts=ferese; org.freedesktop.impl.portal.Inhibit=ferese; +org.freedesktop.impl.portal.InputCapture=ferese; diff --git a/packaging/portal/ferese.portal b/packaging/portal/ferese.portal index 8210c5d..d83f3af 100644 --- a/packaging/portal/ferese.portal +++ b/packaging/portal/ferese.portal @@ -1,4 +1,4 @@ [portal] DBusName=org.freedesktop.impl.portal.desktop.ferese -Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Usb;org.freedesktop.impl.portal.Lockdown;org.freedesktop.impl.portal.GlobalShortcuts;org.freedesktop.impl.portal.Inhibit; +Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Usb;org.freedesktop.impl.portal.Lockdown;org.freedesktop.impl.portal.GlobalShortcuts;org.freedesktop.impl.portal.Inhibit;org.freedesktop.impl.portal.InputCapture; UseIn=Ferese; diff --git a/portal/Cargo.toml b/portal/Cargo.toml index 2e0c0c9..b82ef64 100644 --- a/portal/Cargo.toml +++ b/portal/Cargo.toml @@ -14,6 +14,8 @@ name = "ferese-record" path = "src/record_main.rs" [dependencies] +reis = { version = "0.7.1", features = ["calloop"] } +calloop.workspace = true ferese-protocols = { path = "../crates/ferese-protocols", default-features = false, features = ["client"] } ferese-ipc = { path = "../crates/ferese-ipc" } url = "2.5" diff --git a/portal/src/backend.rs b/portal/src/backend.rs index c211fc2..2b6343f 100644 --- a/portal/src/backend.rs +++ b/portal/src/backend.rs @@ -54,12 +54,15 @@ pub(crate) struct Cancel { impl Cancel { pub(crate) fn stop(&self) { self.stopped.store(true, Ordering::SeqCst); - self.changed.notify_one(); + self.changed.notify_waiters(); } pub(crate) async fn wait(&self) { + let changed = self.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); if !self.stopped.load(Ordering::SeqCst) { - self.changed.notified().await; + changed.await; } } } @@ -912,6 +915,7 @@ pub async fn run() -> Result<(), Box> { let lockdown = crate::lockdown::Lockdown::new(); let shortcuts = crate::shortcuts::GlobalShortcuts::new(requests.clone()); let inhibit = crate::inhibit::Inhibit::default(); + let input_capture = crate::input_capture::InputCapture::new(requests.clone()); let connection = zbus::connection::Builder::session()? .name("org.freedesktop.impl.portal.desktop.ferese")? .serve_at(PATH, backend.clone())? @@ -923,6 +927,7 @@ pub async fn run() -> Result<(), Box> { .serve_at(PATH, lockdown.clone())? .serve_at(PATH, shortcuts.clone())? .serve_at(PATH, inhibit.clone())? + .serve_at(PATH, input_capture.clone())? .serve_at( "/org/ferese/ScreenRecorder", RecorderControl(backend.clone()), @@ -950,6 +955,9 @@ pub async fn run() -> Result<(), Box> { requests.revoke_stale(owner.as_deref()).await; shortcuts.revoke_stale(&connection, owner.as_deref()).await; inhibit.revoke_stale(&connection, owner.as_deref()).await; + input_capture + .revoke_stale(&connection, owner.as_deref()) + .await; let stale: Vec<_> = backend .sessions diff --git a/portal/src/bridge.rs b/portal/src/bridge.rs index b4ffb2a..62e76f2 100644 --- a/portal/src/bridge.rs +++ b/portal/src/bridge.rs @@ -72,6 +72,15 @@ impl Bridge { .map_err(|error| error.to_string())? } + pub(crate) async fn capture_watch(&self, session: u64) -> Result { + self.request( + "input-capture-watch", + serde_json::json!({"session":session}), + true, + ) + .await + } + pub(crate) async fn closed(&self) { use std::io::Read; let Ok(reader) = self.0.shutdown.try_clone() else { diff --git a/portal/src/eis.rs b/portal/src/eis.rs new file mode 100644 index 0000000..b9d55fe --- /dev/null +++ b/portal/src/eis.rs @@ -0,0 +1,471 @@ +use std::{ + fs::File, + io::Write, + os::{ + fd::{AsFd, OwnedFd}, + unix::net::UnixStream, + }, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + +use calloop::{EventLoop, LoopSignal, PostAction, channel}; +use reis::{ + calloop::{EisRequestSource, EisRequestSourceEvent}, + eis, + enumflags2::BitFlags, + request::{Connection, Device, DeviceCapability, EisRequest}, +}; +use serde_json::Value; + +use crate::backend::Cancel; + +pub(crate) struct Worker { + sender: channel::SyncSender>, + signal: LoopSignal, + pub(crate) ready: Arc, +} + +impl Worker { + pub(crate) fn start( + capabilities: u32, + keymap: &str, + cancel: Arc, + ) -> Result<(Self, OwnedFd), String> { + if keymap.is_empty() || keymap.len() > 256 * 1024 { + return Err("Invalid captured keyboard map".into()); + } + let mut keymap_file = tempfile::tempfile().map_err(|e| e.to_string())?; + keymap_file + .write_all(keymap.as_bytes()) + .and_then(|_| keymap_file.write_all(&[0])) + .map_err(|e| e.to_string())?; + let keymap_size = keymap.len() as u32 + 1; + let (server, client) = UnixStream::pair().map_err(|e| e.to_string())?; + let ready = Arc::new(AtomicBool::new(false)); + let thread_ready = ready.clone(); + let (startup, started) = std::sync::mpsc::sync_channel(1); + std::thread::Builder::new() + .name("ferese-input-eis".into()) + .spawn(move || { + struct Finish(Arc); + impl Drop for Finish { + fn drop(&mut self) { + self.0.stop(); + } + } + let _finish = Finish(cancel); + let run = || { + let context = eis::Context::new(server).map_err(|e| e.to_string())?; + let mut event_loop = + EventLoop::::try_new().map_err(|e| e.to_string())?; + let signal = event_loop.get_signal(); + let (sender, receiver) = channel::sync_channel(4); + event_loop + .handle() + .insert_source(receiver, |event, _, state| match event { + channel::Event::Msg(events) => state.events(events), + channel::Event::Closed => state.signal.stop(), + }) + .map_err(|e| e.to_string())?; + event_loop + .handle() + .insert_source( + EisRequestSource::new(context, 1), + |event, connection, state| { + let action = match event { + Ok(event) => state.request(connection, event), + Err(_) => { + state.signal.stop(); + PostAction::Remove + } + }; + if connection.flush().is_err() { + state.signal.stop(); + } + Ok(action) + }, + ) + .map_err(|e| e.to_string())?; + let mut state = State { + signal: signal.clone(), + ready: thread_ready.clone(), + capabilities, + keymap: keymap_file, + keymap_size, + connection: None, + keyboard: None, + pointer: None, + activation: None, + keyboard_ready: false, + pointer_ready: false, + }; + startup + .send(Ok((sender, signal))) + .map_err(|_| "Input transport startup cancelled".to_owned())?; + let _ = event_loop.run(None, &mut state, |_| {}); + if let Some(connection) = state.connection.take() { + connection + .disconnected(eis::connection::DisconnectReason::Disconnected, None); + } + state.ready.store(false, Ordering::SeqCst); + Ok::<(), String>(()) + }; + if let Err(error) = run() { + let _ = startup.send(Err(error)); + } + }) + .map_err(|e| e.to_string())?; + let (sender, signal) = started + .recv_timeout(std::time::Duration::from_secs(2)) + .map_err(|_| "Input transport startup timed out".to_owned())??; + Ok(( + Self { + sender, + signal, + ready, + }, + client.into(), + )) + } + + pub(crate) fn send(&self, events: Vec) -> Result<(), String> { + self.sender + .try_send(events) + .map_err(|_| "Input transport is closed or stalled".into()) + } + + pub(crate) fn stop(&self) { + self.signal.stop(); + self.signal.wakeup(); + } +} + +impl Drop for Worker { + fn drop(&mut self) { + self.stop(); + } +} + +struct State { + signal: LoopSignal, + ready: Arc, + capabilities: u32, + keymap: File, + keymap_size: u32, + connection: Option, + keyboard: Option, + pointer: Option, + activation: Option, + keyboard_ready: bool, + pointer_ready: bool, +} + +impl State { + fn request(&mut self, connection: &Connection, event: EisRequestSourceEvent) -> PostAction { + match event { + EisRequestSourceEvent::Connected => { + if connection.context_type() != eis::handshake::ContextType::Receiver { + connection.disconnected( + eis::connection::DisconnectReason::Mode, + Some("Input capture requires a receiver context"), + ); + self.signal.stop(); + return PostAction::Remove; + } + let mut capabilities = BitFlags::::empty(); + if self.capabilities & 1 != 0 { + capabilities |= DeviceCapability::Keyboard; + } + if self.capabilities & 2 != 0 { + capabilities |= DeviceCapability::Pointer + | DeviceCapability::Button + | DeviceCapability::Scroll; + } + let _ = connection.add_seat(Some("Ferese input capture"), capabilities); + self.connection = Some(connection.clone()); + } + EisRequestSourceEvent::Request(EisRequest::Bind(request)) => { + let keyboard = request.capabilities.contains(DeviceCapability::Keyboard) + && self.capabilities & 1 != 0; + let pointer = request.capabilities.contains(DeviceCapability::Pointer) + && self.capabilities & 2 != 0; + if !keyboard && let Some(device) = self.keyboard.take() { + device.remove(); + self.keyboard_ready = false; + } + if !pointer && let Some(device) = self.pointer.take() { + device.remove(); + self.pointer_ready = false; + } + if keyboard && self.keyboard.is_none() { + let device = request.seat.add_device( + Some("Ferese keyboard"), + eis::device::DeviceType::Virtual, + DeviceCapability::Keyboard.into(), + |device| { + if let Some(keyboard) = device.interface::() { + keyboard.keymap( + eis::keyboard::KeymapType::Xkb, + self.keymap_size, + self.keymap.as_fd(), + ); + } + }, + ); + self.keyboard_ready = device.device().version() < 3; + device.resumed(); + self.keyboard = Some(device); + } + if pointer && self.pointer.is_none() { + let caps = request.capabilities + & (DeviceCapability::Pointer + | DeviceCapability::Button + | DeviceCapability::Scroll); + let device = request.seat.add_device( + Some("Ferese pointer"), + eis::device::DeviceType::Virtual, + caps, + |_| {}, + ); + self.pointer_ready = device.device().version() < 3; + device.resumed(); + self.pointer = Some(device); + } + let ready = self.keyboard.is_some() || self.pointer.is_some(); + self.ready.store( + ready + && (self.keyboard.is_none() || self.keyboard_ready) + && (self.pointer.is_none() || self.pointer_ready), + Ordering::SeqCst, + ); + if self.activation.is_some() || !ready { + self.signal.stop(); + } + } + EisRequestSourceEvent::Request(EisRequest::Ready(request)) => { + if self + .keyboard + .as_ref() + .is_some_and(|device| device.device() == request.device.device()) + { + self.keyboard_ready = true; + } else if self + .pointer + .as_ref() + .is_some_and(|device| device.device() == request.device.device()) + { + self.pointer_ready = true; + } else { + self.signal.stop(); + return PostAction::Remove; + } + self.ready.store( + (self.keyboard.is_none() || self.keyboard_ready) + && (self.pointer.is_none() || self.pointer_ready), + Ordering::SeqCst, + ); + } + EisRequestSourceEvent::Request( + EisRequest::Disconnect | EisRequest::DeviceClosed(_), + ) => { + self.signal.stop(); + return PostAction::Remove; + } + EisRequestSourceEvent::Request(_) => { + connection.disconnected( + eis::connection::DisconnectReason::Mode, + Some("Input capture does not accept injected input"), + ); + self.signal.stop(); + return PostAction::Remove; + } + } + PostAction::Continue + } + + fn events(&mut self, events: Vec) { + for event in events { + match event["type"].as_str() { + Some("activated") => { + let activation = event["activation_id"].as_u64().unwrap_or(0) as u32; + self.activation = Some(activation); + for device in [&self.keyboard, &self.pointer].into_iter().flatten() { + device.start_emulating(activation); + } + } + Some("deactivated" | "disabled" | "closed") => { + if self.activation.take().is_some() { + for device in [&self.keyboard, &self.pointer].into_iter().flatten() { + device.stop_emulating(); + } + } + } + Some("keys") if self.activation.is_some() => { + if let Some(device) = &self.keyboard + && let Some(keyboard) = device.interface::() + { + for key in event["keys"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_u64) + { + keyboard.key(key as u32, eis::keyboard::KeyState::Press); + } + device.frame(now()); + } + } + Some("key") if self.activation.is_some() => { + if let Some(device) = &self.keyboard + && let Some(keyboard) = device.interface::() + { + keyboard.key( + event["key"].as_u64().unwrap_or(0) as u32, + if event["pressed"] == true { + eis::keyboard::KeyState::Press + } else { + eis::keyboard::KeyState::Released + }, + ); + device.frame(now()); + } + } + Some("motion" | "button" | "scroll") if self.activation.is_some() => { + if let Some(device) = &self.pointer { + match event["type"].as_str() { + Some("motion") => { + if let Some(pointer) = device.interface::() { + pointer.motion_relative( + event["x"].as_f64().unwrap_or(0.0) as f32, + event["y"].as_f64().unwrap_or(0.0) as f32, + ); + } + } + Some("button") => { + if let Some(button) = device.interface::() { + button.button( + event["button"].as_u64().unwrap_or(0) as u32, + if event["pressed"] == true { + eis::button::ButtonState::Press + } else { + eis::button::ButtonState::Released + }, + ); + } + } + Some("scroll") => { + if let Some(scroll) = device.interface::() { + let x = event["x"].as_f64().unwrap_or(0.0) as f32; + let y = event["y"].as_f64().unwrap_or(0.0) as f32; + if !event["v120_x"].is_null() || !event["v120_y"].is_null() { + scroll.scroll_discrete( + event["v120_x"].as_f64().unwrap_or(0.0) as i32, + event["v120_y"].as_f64().unwrap_or(0.0) as i32, + ); + } else if x != 0.0 || y != 0.0 { + scroll.scroll(x, y); + } + let stop_x = event["stop_x"] == true; + let stop_y = event["stop_y"] == true; + if stop_x || stop_y { + scroll.scroll_stop(stop_x as u32, stop_y as u32, 0); + } + } + } + _ => (), + } + device.frame(now()); + } + } + _ => (), + } + } + if let Some(connection) = &self.connection + && connection.flush().is_err() + { + self.signal.stop(); + } + } +} + +fn now() -> u64 { + let mut time = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + // SAFETY: the initialized output pointer covers a timespec allocation. + if unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut time) } == 0 { + time.tv_sec as u64 * 1_000_000 + time.tv_nsec as u64 / 1_000 + } else { + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::{ + process::{Command, Stdio}, + time::{Duration, Instant}, + }; + + #[test] + #[ignore = "requires Python and the system libei receiver library"] + fn libei_receives_keymap_motion_keyboard_and_explicit_axis_stop() { + let cancel = Arc::new(Cancel::default()); + let map = "xkb_keymap { xkb_keycodes { include \"evdev+aliases(qwerty)\" }; xkb_types { include \"complete\" }; xkb_compatibility { include \"complete\" }; xkb_symbols { include \"pc+us+inet(evdev)\" }; };"; + let (worker, fd) = Worker::start(3, map, cancel.clone()).unwrap(); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../scripts/tests/fixtures/eis-receiver.py"); + let mut client = Command::new("python3") + .arg(fixture) + .stdin(Stdio::from(fd)) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !worker.ready.load(Ordering::SeqCst) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + if !worker.ready.load(Ordering::SeqCst) { + let _ = client.kill(); + let output = client.wait_with_output().unwrap(); + panic!( + "Receiver did not bind: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + worker + .send(vec![ + json!({"type":"activated", "activation_id":1}), + json!({"type":"motion", "x":10.0, "y":-5.0}), + json!({"type":"motion", "x":10.0, "y":-5.0}), + json!({"type":"key", "key":30, "pressed":true}), + json!({"type":"key", "key":30, "pressed":false}), + json!({"type":"scroll", "x":0.0, "y":4.0}), + json!({"type":"scroll", "x":0.0, "y":0.0, "stop_x":false, "stop_y":true}), + json!({"type":"deactivated"}), + ]) + .unwrap(); + let output = client.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("\"keymap\": true")); + let deadline = Instant::now() + Duration::from_secs(2); + while !cancel.stopped.load(Ordering::SeqCst) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + cancel.stopped.load(Ordering::SeqCst), + "Receiver disconnect must revoke capture" + ); + } +} diff --git a/portal/src/input_capture.rs b/portal/src/input_capture.rs new file mode 100644 index 0000000..a46fa0c --- /dev/null +++ b/portal/src/input_capture.rs @@ -0,0 +1,784 @@ +use std::{ + collections::HashMap, + sync::{Arc, atomic::Ordering}, + time::Duration, +}; + +use serde_json::json; +use tokio::sync::Mutex; +use zbus::{ + Connection, + message::Header, + object_server::SignalEmitter, + zvariant::{OwnedFd, OwnedObjectPath, Value}, +}; + +use crate::{ + backend::{Cancel, Options, authorize}, + bridge::Bridge, + desktop::Requests, + eis::Worker, +}; + +const PATH: &str = "/org/freedesktop/portal/desktop"; + +#[derive(Default)] +struct State { + starting: bool, + started: bool, + id: u64, + capabilities: u32, + keymap: String, + bridge: Option, + watch: Option, + worker: Option, +} + +struct Session { + owner: String, + app: String, + cancel: Arc, + state: Mutex, +} + +#[derive(Clone, Default)] +pub(crate) struct InputCapture { + sessions: Arc>>>, + requests: Requests, +} + +impl InputCapture { + pub(crate) fn new(requests: Requests) -> Self { + Self { + requests, + ..Self::default() + } + } + + async fn session( + &self, + connection: &Connection, + header: &Header<'_>, + path: &OwnedObjectPath, + app: &str, + ) -> zbus::fdo::Result> { + let owner = authorize(connection, header).await?; + self.sessions + .lock() + .await + .get(path.as_str()) + .filter(|session| session.owner == owner && session.app == app) + .cloned() + .ok_or_else(|| invalid("Unknown input capture session")) + } + + async fn end(&self, connection: &Connection, path: &str) { + let session = self.sessions.lock().await.remove(path); + if let Some(session) = session { + session.cancel.stop(); + let state = session.state.lock().await; + if let Some(worker) = &state.worker { + worker.stop(); + } + if let Some(bridge) = &state.bridge { + bridge.close(); + } + if let Some(watch) = &state.watch { + watch.close(); + } + if let Ok(emitter) = SignalEmitter::new(connection, path) { + let _ = SessionObject::closed(&emitter).await; + } + let connection = connection.clone(); + let path = path.to_owned(); + tokio::spawn(async move { + let _ = connection + .object_server() + .remove::(path) + .await; + }); + } + } + + pub(crate) async fn revoke_stale(&self, connection: &Connection, owner: Option<&str>) { + let stale = self + .sessions + .lock() + .await + .iter() + .filter_map(|(path, session)| { + (Some(session.owner.as_str()) != owner).then_some(path.clone()) + }) + .collect::>(); + for path in stale { + self.end(connection, &path).await; + } + } + + async fn native(session: &Session) -> zbus::fdo::Result<(Bridge, u64)> { + let state = session.state.lock().await; + if !state.started || session.cancel.stopped.load(Ordering::SeqCst) { + return Err(invalid("Start the session first")); + } + Ok(( + state + .bridge + .clone() + .ok_or_else(|| invalid("Missing capture connection"))?, + state.id, + )) + } + + async fn watch( + self, + connection: Connection, + path: OwnedObjectPath, + session: Arc, + bridge: Bridge, + id: u64, + ) { + loop { + let response = tokio::select! { + _ = session.cancel.wait() => break, + result = bridge.capture_watch(id) => result, + }; + let Ok(response) = response else { + break; + }; + let Some(events) = response["events"].as_array() else { + break; + }; + let state = session.state.lock().await; + if session.cancel.stopped.load(Ordering::SeqCst) { + break; + } + let transport = state + .worker + .as_ref() + .map(|worker| worker.send(events.clone())); + if transport.is_some_and(|result| result.is_err()) { + break; + } + let mut closed = false; + let Ok(emitter) = SignalEmitter::new(&connection, PATH) else { + break; + }; + for event in events { + let result = match event["type"].as_str() { + Some("activated") => { + let mut options = Options::new(); + options.insert( + "activation_id".into(), + (event["activation_id"].as_u64().unwrap_or(0) as u32).into(), + ); + options.insert( + "barrier_id".into(), + (event["barrier_id"].as_u64().unwrap_or(0) as u32).into(), + ); + if let Some(position) = event["cursor_position"] + .as_array() + .filter(|position| position.len() == 2) + { + if let Ok(position) = Value::from(( + position[0].as_f64().unwrap_or(0.0), + position[1].as_f64().unwrap_or(0.0), + )) + .try_to_owned() + { + options.insert("cursor_position".into(), position); + } + } + Self::activated(&emitter, path.clone(), options).await + } + Some("deactivated") => { + Self::deactivated( + &emitter, + path.clone(), + Options::from([( + "activation_id".into(), + (event["activation_id"].as_u64().unwrap_or(0) as u32).into(), + )]), + ) + .await + } + Some("disabled") => { + Self::disabled(&emitter, path.clone(), Options::new()).await + } + Some("zones") => { + Self::zones_changed(&emitter, path.clone(), Options::new()).await + } + Some("closed") => { + closed = true; + Ok(()) + } + _ => Ok(()), + }; + if result.is_err() { + closed = true; + } + } + drop(state); + if closed { + break; + } + } + self.end(&connection, path.as_str()).await; + } +} + +fn invalid(message: &str) -> zbus::fdo::Error { + zbus::fdo::Error::InvalidArgs(message.into()) +} + +fn failed(error: String) -> zbus::fdo::Error { + zbus::fdo::Error::Failed(error) +} + +fn capabilities(options: &Options) -> zbus::fdo::Result { + let requested = options + .get("capabilities") + .ok_or_else(|| invalid("Missing requested capabilities"))?; + let requested = + u32::try_from(requested).map_err(|_| invalid("Invalid requested capabilities"))?; + if requested == 0 || requested & 3 == 0 { + return Err(invalid("No supported capabilities requested")); + } + Ok(requested & 3) +} + +#[zbus::interface(name = "org.freedesktop.impl.portal.InputCapture")] +impl InputCapture { + #[zbus(property, name = "version")] + fn version(&self) -> u32 { + 2 + } + + #[zbus(property)] + fn supported_capabilities(&self) -> u32 { + 3 + } + + async fn create_session2( + &self, + session_handle: OwnedObjectPath, + app_id: String, + _options: Options, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result { + let owner = authorize(connection, &header).await?; + if !session_handle + .as_str() + .starts_with(&format!("{PATH}/session/")) + || session_handle.as_str().len() > 512 + || app_id.len() > 512 + { + return Err(invalid("Invalid session")); + } + let mut sessions = self.sessions.lock().await; + if sessions.len() >= 8 || sessions.contains_key(session_handle.as_str()) { + return Err(invalid("Session limit reached or duplicate session")); + } + if !connection + .object_server() + .at( + &session_handle, + SessionObject { + backend: self.clone(), + path: session_handle.to_string(), + owner: owner.clone(), + }, + ) + .await? + { + return Err(invalid("Session object already exists")); + } + sessions.insert( + session_handle.to_string(), + Arc::new(Session { + owner, + app: app_id, + cancel: Arc::default(), + state: Mutex::default(), + }), + ); + Ok(Options::new()) + } + + #[allow(clippy::too_many_arguments)] + async fn create_session( + &self, + handle: OwnedObjectPath, + session_handle: OwnedObjectPath, + app_id: String, + parent_window: String, + options: Options, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<(u32, Options)> { + capabilities(&options)?; + self.create_session2( + session_handle.clone(), + app_id.clone(), + Options::new(), + connection, + header.clone(), + ) + .await?; + let result = self + .start( + handle, + session_handle.clone(), + app_id, + parent_window, + options, + connection, + header, + ) + .await; + let (response, mut results) = match result { + Ok(result) => result, + Err(error) => { + self.end(connection, session_handle.as_str()).await; + return Err(error); + } + }; + if response == 0 { + results.insert( + "session_id".into(), + Value::from(session_handle.to_string()) + .try_to_owned() + .map_err(|e| failed(e.to_string()))?, + ); + } + Ok((response, results)) + } + + #[allow(clippy::too_many_arguments)] + async fn start( + &self, + handle: OwnedObjectPath, + session_handle: OwnedObjectPath, + app_id: String, + parent_window: String, + options: Options, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<(u32, Options)> { + let session = self + .session(connection, &header, &session_handle, &app_id) + .await?; + let caps = capabilities(&options)?; + crate::restore::persist_mode(&options)?; + let cancel = self.requests.begin(connection, &header, &handle).await?; + { + let mut state = session.state.lock().await; + if state.starting || state.started { + self.requests.end(connection, &handle).await; + return Err(invalid("Input capture can only be started once")); + } + state.starting = true; + } + let title = match caps { + 1 => "Share keyboard input?", + 2 => "Share pointer input?", + _ => "Share keyboard and pointer?", + }; + let consent = crate::desktop::consent( + &app_id, + &parent_window, + title, + "Allow input capture at the screen edges this app configures. Captured input is sent to this app until it releases control. Press Ctrl+Alt+Escape to return control to Ferese.", + "Allow input capture", + None, + ); + let approved = tokio::select! { + _ = cancel.wait() => Ok(false), + _ = session.cancel.wait() => Ok(false), + result = consent => result, + }; + let result = if approved == Ok(true) + && !cancel.stopped.load(Ordering::SeqCst) + && !session.cancel.stopped.load(Ordering::SeqCst) + { + async { + let bridge = Bridge::connect()?; + let reply = bridge + .call("input-capture-register", json!({"capabilities":caps})) + .await?; + let id = reply["session"] + .as_u64() + .ok_or("Missing capture session ID")?; + let keymap = reply["keymap"] + .as_str() + .ok_or("Missing keyboard map")? + .to_owned(); + let watch = Bridge::connect()?; + let mut state = session.state.lock().await; + if cancel.stopped.load(Ordering::SeqCst) + || session.cancel.stopped.load(Ordering::SeqCst) + { + bridge.close(); + return Err("Input capture approval was cancelled".to_owned()); + } + state.started = true; + state.starting = false; + state.id = id; + state.capabilities = caps; + state.keymap = keymap; + state.bridge = Some(bridge); + state.watch = Some(watch.clone()); + tokio::spawn(self.clone().watch( + connection.clone(), + session_handle.clone(), + session.clone(), + watch, + id, + )); + Ok(Options::from([ + ("capabilities".into(), caps.into()), + ("clipboard_enabled".into(), false.into()), + ("persist_mode".into(), 0u32.into()), + ])) + } + .await + } else { + Err(approved.err().unwrap_or_else(|| "Cancelled".into())) + }; + self.requests.end(connection, &handle).await; + match result { + Ok(results) => Ok((0, results)), + Err(error) => { + let response = if error == "Cancelled" + || cancel.stopped.load(Ordering::SeqCst) + || session.cancel.stopped.load(Ordering::SeqCst) + { + 1 + } else { + eprintln!("ferese input capture: {error}"); + 2 + }; + self.end(connection, session_handle.as_str()).await; + Ok((response, Options::new())) + } + } + } + + async fn get_zones( + &self, + handle: OwnedObjectPath, + session_handle: OwnedObjectPath, + app_id: String, + _options: Options, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<(u32, Options)> { + let session = self + .session(connection, &header, &session_handle, &app_id) + .await?; + let (bridge, id) = Self::native(&session).await?; + let cancel = self.requests.begin(connection, &header, &handle).await?; + let result = tokio::select! { + biased; + _ = cancel.wait() => None, + _ = session.cancel.wait() => None, + result = bridge.call("input-capture-zones", json!({"session":id})) => Some(result), + }; + self.requests.end(connection, &handle).await; + if cancel.stopped.load(Ordering::SeqCst) + || session.cancel.stopped.load(Ordering::SeqCst) + || result.is_none() + { + return Ok((1, Options::new())); + } + let result = result.unwrap().map_err(failed)?; + let zones: Vec<(u32, u32, i32, i32)> = serde_json::from_value(result["zones"].clone()) + .map_err(|_| invalid("Invalid native capture zones"))?; + Ok(( + 0, + Options::from([ + ( + "zones".into(), + Value::from(zones) + .try_to_owned() + .map_err(|e| failed(e.to_string()))?, + ), + ( + "zone_set".into(), + (result["zone_set"].as_u64().unwrap_or(0) as u32).into(), + ), + ]), + )) + } + + #[allow(clippy::too_many_arguments)] + async fn set_pointer_barriers( + &self, + handle: OwnedObjectPath, + session_handle: OwnedObjectPath, + app_id: String, + _options: Options, + barriers: Vec, + zone_set: u32, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<(u32, Options)> { + let session = self + .session(connection, &header, &session_handle, &app_id) + .await?; + let (bridge, id) = Self::native(&session).await?; + if barriers.len() > 64 { + return Err(invalid("Too many pointer barriers")); + } + let barriers = barriers + .into_iter() + .map(|barrier| { + let id = u32::try_from( + barrier + .get("barrier_id") + .ok_or_else(|| invalid("Missing barrier ID"))?, + ) + .map_err(|_| invalid("Invalid barrier ID"))?; + let position = barrier + .get("position") + .ok_or_else(|| invalid("Missing barrier position"))? + .try_clone() + .map_err(|_| invalid("Invalid position"))?; + let position: (i32, i32, i32, i32) = position + .try_into() + .map_err(|_| invalid("Invalid barrier position"))?; + Ok(json!({"id":id,"position":[position.0,position.1,position.2,position.3]})) + }) + .collect::>>()?; + let cancel = self.requests.begin(connection, &header, &handle).await?; + let result = tokio::select! { + biased; + _ = cancel.wait() => None, + _ = session.cancel.wait() => None, + result = bridge.call("input-capture-barriers", json!({"session":id,"zone_set":zone_set,"barriers":barriers})) => Some(result), + }; + self.requests.end(connection, &handle).await; + if cancel.stopped.load(Ordering::SeqCst) + || session.cancel.stopped.load(Ordering::SeqCst) + || result.is_none() + { + self.end(connection, session_handle.as_str()).await; + return Ok((1, Options::new())); + } + let result = result.unwrap().map_err(failed)?; + let failed_barriers: Vec = serde_json::from_value(result["failed_barriers"].clone()) + .map_err(|_| invalid("Invalid native barrier response"))?; + Ok(( + 0, + Options::from([( + "failed_barriers".into(), + Value::from(failed_barriers) + .try_to_owned() + .map_err(|e| failed(e.to_string()))?, + )]), + )) + } + + #[zbus(name = "ConnectToEIS")] + async fn connect_to_eis( + &self, + session_handle: OwnedObjectPath, + app_id: String, + _options: Options, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result { + let session = self + .session(connection, &header, &session_handle, &app_id) + .await?; + Self::native(&session).await?; + let mut state = session.state.lock().await; + if state.worker.is_some() { + return Err(invalid("EIS has already been connected")); + } + let capabilities = state.capabilities; + let keymap = state.keymap.clone(); + let cancel = session.cancel.clone(); + let (worker, fd) = + tokio::task::spawn_blocking(move || Worker::start(capabilities, &keymap, cancel)) + .await + .map_err(|e| failed(e.to_string()))? + .map_err(failed)?; + state.worker = Some(worker); + Ok(fd.into()) + } + + async fn enable( + &self, + session_handle: OwnedObjectPath, + app_id: String, + _options: Options, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<(u32, Options)> { + let session = self + .session(connection, &header, &session_handle, &app_id) + .await?; + let (bridge, id) = Self::native(&session).await?; + let ready = session + .state + .lock() + .await + .worker + .as_ref() + .map(|worker| worker.ready.clone()) + .ok_or_else(|| invalid("Connect to EIS before enabling input capture"))?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while !ready.load(Ordering::SeqCst) { + if session.cancel.stopped.load(Ordering::SeqCst) + || tokio::time::Instant::now() >= deadline + { + return Err(invalid("EIS receiver has not bound an input device")); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + bridge + .call("input-capture-enable", json!({"session":id})) + .await + .map_err(failed)?; + Ok((0, Options::new())) + } + + async fn disable( + &self, + session_handle: OwnedObjectPath, + app_id: String, + _options: Options, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<(u32, Options)> { + let session = self + .session(connection, &header, &session_handle, &app_id) + .await?; + let (bridge, id) = Self::native(&session).await?; + bridge + .call("input-capture-disable", json!({"session":id})) + .await + .map_err(failed)?; + Ok((0, Options::new())) + } + + async fn release( + &self, + session_handle: OwnedObjectPath, + app_id: String, + options: Options, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<(u32, Options)> { + let session = self + .session(connection, &header, &session_handle, &app_id) + .await?; + let (bridge, id) = Self::native(&session).await?; + let activation = options + .get("activation_id") + .map(u32::try_from) + .transpose() + .map_err(|_| invalid("Invalid activation ID"))?; + let position = options + .get("cursor_position") + .map(|value| { + let position: (f64, f64) = value + .try_clone() + .map_err(|_| invalid("Invalid cursor position"))? + .try_into() + .map_err(|_| invalid("Invalid cursor position"))?; + if !position.0.is_finite() || !position.1.is_finite() { + return Err(invalid("Invalid cursor position")); + } + Ok(position) + }) + .transpose()?; + bridge + .call( + "input-capture-release", + json!({"session":id,"activation_id":activation,"cursor_position":position}), + ) + .await + .map_err(failed)?; + Ok((0, Options::new())) + } + + #[zbus(signal)] + async fn activated( + emitter: &SignalEmitter<'_>, + session_handle: OwnedObjectPath, + options: Options, + ) -> zbus::Result<()>; + + #[zbus(signal)] + async fn deactivated( + emitter: &SignalEmitter<'_>, + session_handle: OwnedObjectPath, + options: Options, + ) -> zbus::Result<()>; + + #[zbus(signal)] + async fn disabled( + emitter: &SignalEmitter<'_>, + session_handle: OwnedObjectPath, + options: Options, + ) -> zbus::Result<()>; + + #[zbus(signal)] + async fn zones_changed( + emitter: &SignalEmitter<'_>, + session_handle: OwnedObjectPath, + options: Options, + ) -> zbus::Result<()>; +} + +struct SessionObject { + backend: InputCapture, + path: String, + owner: String, +} + +#[zbus::interface(name = "org.freedesktop.impl.portal.Session")] +impl SessionObject { + #[zbus(property, name = "version")] + fn version(&self) -> u32 { + 1 + } + + async fn close( + &self, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + if header.sender().map(|owner| owner.as_str()) != Some(self.owner.as_str()) { + return Err(zbus::fdo::Error::AccessDenied("Wrong session owner".into())); + } + self.backend.end(connection, &self.path).await; + Ok(()) + } + + #[zbus(signal)] + async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn requested_capabilities_are_required_and_supported_subset_is_returned() { + assert!(capabilities(&Options::new()).is_err()); + for requested in [0u32, 4, 8] { + assert!( + capabilities(&Options::from([("capabilities".into(), requested.into())])).is_err() + ); + } + for (requested, supported) in [(1u32, 1), (2, 2), (3, 3), (7, 3), (9, 1)] { + assert_eq!( + capabilities(&Options::from([("capabilities".into(), requested.into())])).unwrap(), + supported + ); + } + } +} diff --git a/portal/src/main.rs b/portal/src/main.rs index fbef948..bdeaccc 100644 --- a/portal/src/main.rs +++ b/portal/src/main.rs @@ -3,7 +3,9 @@ mod bridge; mod capture; mod consent; mod desktop; +mod eis; mod inhibit; +mod input_capture; mod lockdown; mod parent; mod permissions; diff --git a/scripts/tests/fixtures/eis-receiver.py b/scripts/tests/fixtures/eis-receiver.py new file mode 100644 index 0000000..94ad1d0 --- /dev/null +++ b/scripts/tests/fixtures/eis-receiver.py @@ -0,0 +1,77 @@ +"""Receive Ferese's private test EIS connection through stdin using real libei.""" +import ctypes as C +import ctypes.util +import json +import os +import select +import time + +lib = C.CDLL(ctypes.util.find_library("ei")) + + +def function(name, result, *arguments): + value = getattr(lib, name) + value.restype = result + value.argtypes = list(arguments) + return value + + +ptr = C.c_void_p +new = function("ei_new_receiver", ptr, ptr) +setup = function("ei_setup_backend_fd", C.c_int, ptr, C.c_int) +dispatch = function("ei_dispatch", None, ptr) +next_event = function("ei_get_event", ptr, ptr) +kind = function("ei_event_get_type", C.c_int, ptr) +seat = function("ei_event_get_seat", ptr, ptr) +device = function("ei_event_get_device", ptr, ptr) +unref_event = function("ei_event_unref", ptr, ptr) +unref = function("ei_unref", ptr, ptr) +bind = function("ei_seat_bind_capabilities", None, ptr) +keymap = function("ei_device_keyboard_get_keymap", ptr, ptr) +map_fd = function("ei_keymap_get_fd", C.c_int, ptr) +map_size = function("ei_keymap_get_size", C.c_size_t, ptr) +dx = function("ei_event_pointer_get_dx", C.c_double, ptr) +dy = function("ei_event_pointer_get_dy", C.c_double, ptr) +key = function("ei_event_keyboard_get_key", C.c_uint32, ptr) +pressed = function("ei_event_keyboard_get_key_is_press", C.c_bool, ptr) +stop_x = function("ei_event_scroll_get_stop_x", C.c_bool, ptr) +stop_y = function("ei_event_scroll_get_stop_y", C.c_bool, ptr) +fd = function("ei_get_fd", C.c_int, ptr) +context = new(None) +assert context and setup(context, os.dup(0)) == 0 +seen = {"motion": [], "keys": [], "stops": [], "starts": 0, "ends": 0, "keymap": False} +deadline = time.monotonic() + 10 +try: + while time.monotonic() < deadline: + select.select([fd(context)], [], [], 0.05) + dispatch(context) + while event := next_event(context): + event_type = kind(event) + if event_type == 2: + raise AssertionError("EIS disconnected before completing capture") + if event_type == 3: + bind(seat(event), C.c_int(1), C.c_int(4), C.c_int(16), C.c_int(32), C.c_void_p()) + elif event_type == 5: + if mapping := keymap(device(event)): + content = os.pread(map_fd(mapping), map_size(mapping), 0) + assert content.endswith(b"\0") and b"xkb_keymap" in content + seen["keymap"] = True + elif event_type == 200: + seen["starts"] += 1 + elif event_type == 201: + seen["ends"] += 1 + elif event_type == 300: + seen["motion"].append([dx(event), dy(event)]) + elif event_type == 700: + seen["keys"].append([key(event), pressed(event)]) + elif event_type == 601: + seen["stops"].append([stop_x(event), stop_y(event)]) + unref_event(event) + if seen["ends"] == 2: + assert seen == {"motion": [[10.0, -5.0], [10.0, -5.0]], "keys": [[30, True], [30, False]], "stops": [[False, True]], "starts": 2, "ends": 2, "keymap": True}, seen + print(json.dumps(seen), flush=True) + break + else: + raise AssertionError(f"Timed out: {seen}") +finally: + unref(context) diff --git a/scripts/tests/test_portal_contracts.py b/scripts/tests/test_portal_contracts.py index a76c6a5..2de7d3e 100644 --- a/scripts/tests/test_portal_contracts.py +++ b/scripts/tests/test_portal_contracts.py @@ -54,7 +54,7 @@ def private_bus_checks(): raise AssertionError((root / "backend.log").read_text()) time.sleep(0.05) node = ET.fromstring(xml) - for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown", "GlobalShortcuts", "Inhibit"): + for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown", "GlobalShortcuts", "Inhibit", "InputCapture"): interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']") assert interface is not None, name expected = ET.parse(f"/usr/share/dbus-1/interfaces/org.freedesktop.impl.portal.{name}.xml").getroot().find("interface") @@ -110,6 +110,18 @@ def private_bus_checks(): GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), GLib.VariantType.new("(u)"), Gio.DBusCallFlags.NONE, 5000, None).unpack()[0] assert request_name == 1 + capture = "org.freedesktop.impl.portal.InputCapture" + caps, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", (capture, "SupportedCapabilities")), "(v)") + assert caps == 3 + session = PATH + "/session/test/inputcapture" + call(capture, "CreateSession2", GLib.Variant("(osa{sv})", (session, "org.test.Capture", {})), "(a{sv})") + try: + call(capture, "GetZones", GLib.Variant("(oosa{sv})", (PATH + "/request/test/zones", session, "org.test.Capture", {}))) + raise AssertionError("Zones available without approval") + except GLib.Error as error: + assert "InvalidArgs" in str(error), error + bus.call_sync(NAME, session, "org.freedesktop.impl.portal.Session", "Close", None, None, Gio.DBusCallFlags.NONE, 5000, None) + version, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.ScreenCast", "version")), "(v)") assert version == 4 source_types, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.ScreenCast", "AvailableSourceTypes")), "(v)")