Implement native InputCapture with private EIS receiver transport

Add consent, zones and outside-edge barriers, connection-scoped leases, bounded input queues and keyboard/pointer forwarding. Restore local input on release, locking, topology or keymap changes, receiver loss and emergency escape. Preserve held-key state and distinguish finger stops from absent scroll axes.

Validation: independent review cleared; 5 native InputCapture unit tests; 32 portal tests; official D-Bus contract checks on a private bus; real installed libei receiver interoperability test. Live physical barrier and consent interaction remain to be checked in a desktop session.
This commit is contained in:
Samuel Onoja committed 2026-09-30 04:51:08 +01:00
1 parent baca852ee9
commit d26e1511d1
22 files changed
+2349 -11

No files matched your search

Generated
+14
View File
@@ -4886,6 +4886,18 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" 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]] [[package]]
name = "renderdoc-sys" name = "renderdoc-sys"
version = "1.1.0" version = "1.1.0"
@@ -7415,6 +7427,7 @@ checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5"
name = "xdg-desktop-portal-ferese" name = "xdg-desktop-portal-ferese"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"calloop 0.14.4",
"dirs", "dirs",
"ferese-config", "ferese-config",
"ferese-ipc", "ferese-ipc",
@@ -7429,6 +7442,7 @@ dependencies = [
"memmap2 0.9.11", "memmap2 0.9.11",
"pipewire", "pipewire",
"png 0.18.1", "png 0.18.1",
"reis",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
+1 -1
View File
@@ -27,7 +27,7 @@ impl XdgActivationHandler for Ferese {
data: XdgActivationTokenData, data: XdgActivationTokenData,
surface: WlSurface, surface: WlSurface,
) { ) {
if self.session_lock.active { if self.session_lock.active || self.input_capture.captures(1) {
return; return;
} }
let valid = data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE; let valid = data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE;
+1
View File
@@ -108,6 +108,7 @@ pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) {
let policy = layer.cached_state(); let policy = layer.cached_state();
if !state.session_lock.active if !state.session_lock.active
&& !state.input_capture.captures(1)
&& policy.keyboard_interactivity == KeyboardInteractivity::Exclusive && policy.keyboard_interactivity == KeyboardInteractivity::Exclusive
&& matches!(policy.layer, Layer::Top | Layer::Overlay) && matches!(policy.layer, Layer::Top | Layer::Overlay)
{ {
+1 -1
View File
@@ -254,7 +254,7 @@ impl XdgShellHandler for Ferese {
} }
fn grab(&mut self, surface: PopupSurface, seat: wl_seat::WlSeat, serial: Serial) { 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; return;
} }
let Some(seat) = Seat::from_resource(&seat) else { let Some(seat) = Seat::from_resource(&seat) else {
+63
View File
@@ -59,6 +59,27 @@ impl Ferese {
self.focus_lock_surface(); 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 { match event {
InputEvent::GestureSwipeBegin { event } => { InputEvent::GestureSwipeBegin { event } => {
let pointer = seat.get_pointer().expect("seat has a pointer"); let pointer = seat.get_pointer().expect("seat has a pointer");
@@ -143,6 +164,13 @@ impl Ferese {
let keycode = event.key_code(); let keycode = event.key_code();
let state = event.state(); 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::<(), _>( keyboard.input::<(), _>(
self, self,
keycode, keycode,
@@ -160,6 +188,17 @@ impl Ferese {
return FilterResult::Forward; return FilterResult::Forward;
} }
let symbol = keysym.modified_sym().raw(); 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() if data.overview.is_active()
&& matches!(symbol, keysyms::KEY_Return | keysyms::KEY_KP_Enter) && matches!(symbol, keysyms::KEY_Return | keysyms::KEY_KP_Enter)
{ {
@@ -249,12 +288,18 @@ impl Ferese {
FilterResult::Intercept(()) FilterResult::Intercept(())
}, },
); );
if was_captured && !self.input_capture.active() {
self.restore_input_capture_focus();
}
} }
InputEvent::PointerMotionAbsolute { event, .. } => { InputEvent::PointerMotionAbsolute { event, .. } => {
let Some(position) = self.absolute_event_position(&event) else { let Some(position) = self.absolute_event_position(&event) else {
return; return;
}; };
let pointer = self.seat.get_pointer().expect("seat has a pointer"); 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); let position = self.constrain_pointer_position(&pointer, position);
pointer.motion( pointer.motion(
@@ -284,6 +329,9 @@ impl Ferese {
}; };
let delta = event.delta(); let delta = event.delta();
let delta_unaccel = event.delta_unaccel(); let delta_unaccel = event.delta_unaccel();
if self.capture_pointer_motion(position, position + delta, false) {
return;
}
pointer.relative_motion( pointer.relative_motion(
self, self,
@@ -313,6 +361,12 @@ impl Ferese {
crate::backends::direct::render_all(self); crate::backends::direct::render_all(self);
} }
InputEvent::PointerButton { event, .. } => { 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 pointer = self.seat.get_pointer().expect("seat has a pointer");
let serial = SERIAL_COUNTER.next_serial(); let serial = SERIAL_COUNTER.next_serial();
@@ -411,6 +465,12 @@ impl Ferese {
let vertical = event.amount(Axis::Vertical).unwrap_or_else(|| { let vertical = event.amount(Axis::Vertical).unwrap_or_else(|| {
event.amount_v120(Axis::Vertical).unwrap_or(0.0) * 15.0 / 120.0 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"); let pointer = self.seat.get_pointer().expect("seat has a pointer");
if self.scroll_overview_strip( if self.scroll_overview_strip(
pointer.current_location(), pointer.current_location(),
@@ -664,6 +724,9 @@ impl Ferese {
} }
fn focus_window_at(&mut self, position: Point<f64, Logical>, serial: Serial, raise: bool) { fn focus_window_at(&mut self, position: Point<f64, Logical>, serial: Serial, raise: bool) {
if self.input_capture.captures(1) {
return;
}
if self.session_lock.active { if self.session_lock.active {
self.focus_output_at(position); self.focus_output_at(position);
self.focus_lock_surface(); self.focus_lock_surface();
+719
View File
@@ -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<Edge> {
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<f64> {
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<u32>,
events: VecDeque<Value>,
}
#[derive(Default)]
pub(crate) struct InputCapture {
sessions: HashMap<u64, Session>,
zones: Vec<Zone>,
zone_set: u32,
next_session: u64,
next_activation: u32,
active: Option<u64>,
last_absolute: Option<(f64, f64)>,
pub(crate) restore_focus: bool,
waiters: HashMap<u64, (u64, u64, SyncSender<Response>)>,
}
impl InputCapture {
pub(crate) fn update_zones(&mut self, mut zones: Vec<Zone>) {
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::<Vec<_>>();
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<u64, String> {
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<Value, String> {
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::<Vec<_>>()}),
)
}
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<Barrier>,
) -> Result<Vec<u32>, 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<u32>) -> 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::<Vec<_>>();
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<Response>,
) {
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::<Vec<_>>();
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::<Vec<_>>()})
} 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::<Vec<_>>();
for id in ids {
let _ = self.release(id, None);
self.sessions.remove(&id);
}
self.flush();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn zones() -> Vec<Zone> {
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<f64, smithay::utils::Logical>,
to: smithay::utils::Point<f64, smithay::utils::Logical>,
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::<Vec<_>>();
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
}
}
+135 -1
View File
@@ -47,6 +47,7 @@ const DEADLINE_IDLE: Duration = Duration::from_secs(1);
#[derive(Debug)] #[derive(Debug)]
struct IpcCall { struct IpcCall {
native_portal: bool,
owner: u64, owner: u64,
request: Request, request: Request,
response: SyncSender<Response>, response: SyncSender<Response>,
@@ -121,13 +122,42 @@ pub(crate) fn init(
let call = match event { let call = match event {
IpcEvent::Call(call) => call, IpcEvent::Call(call) => call,
IpcEvent::Closed(owner) => { 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_shortcuts.remove(owner);
state.portal_session.remove(owner); state.portal_session.remove(owner);
state.refresh_idle_inhibition(); state.refresh_idle_inhibition();
return; 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) { if let Err(error) = validate_request(&call.request) {
let _ = call.response.send(Response::error( let _ = call.response.send(Response::error(
call.request.id, call.request.id,
@@ -310,6 +340,7 @@ fn serve_connection(
signal: LoopSignal, signal: LoopSignal,
owner: u64, owner: u64,
) { ) {
let native_portal = crate::handlers::window_capture::is_portal(&stream);
let _connection = IpcConnection { let _connection = IpcConnection {
owner, owner,
sender: sender.clone(), sender: sender.clone(),
@@ -337,6 +368,7 @@ fn serve_connection(
let screenshot_requested = let screenshot_requested =
matches!(request.command.as_str(), "screenshot" | "screenshot-window"); matches!(request.command.as_str(), "screenshot" | "screenshot-window");
let call = IpcEvent::Call(IpcCall { let call = IpcEvent::Call(IpcCall {
native_portal,
owner, owner,
request, request,
response, response,
@@ -417,6 +449,8 @@ impl Ferese {
&& !matches!( && !matches!(
request.command.as_str(), request.command.as_str(),
"portal-shortcuts-poll" "portal-shortcuts-poll"
| "input-capture-disable"
| "input-capture-release"
| "get-session-state" | "get-session-state"
| "portal-inhibit" | "portal-inhibit"
| "portal-monitor-register" | "portal-monitor-register"
@@ -525,6 +559,106 @@ impl Ferese {
"portal-shortcuts-poll" => { "portal-shortcuts-poll" => {
return Ok(self.portal_shortcuts.poll(owner, self.session_lock.active)); 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. "exit" => {} // The IPC worker stops the loop after writing the response.
"request-logout" => self.request_logout_confirmation(), "request-logout" => self.request_logout_confirmation(),
"reload-config" => self "reload-config" => self
+4
View File
@@ -10,6 +10,7 @@ mod gestures;
mod grabs; mod grabs;
mod handlers; mod handlers;
mod input; mod input;
mod input_capture;
mod ipc; mod ipc;
mod metrics; mod metrics;
mod overview; mod overview;
@@ -137,6 +138,9 @@ fn main() -> Result<(), Box<dyn Error>> {
} }
// All input/Wayland callbacks have returned, releasing seat locks. // All input/Wayland callbacks have returned, releasing seat locks.
// Coalesce cursor changes and redraw here, never inside cursor_image. // 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(); let wallpaper_retry = state.wallpaper.take_retry_wakeup();
if std::mem::take(&mut state.cursor_redraw_pending) || wallpaper_changed || wallpaper_retry if std::mem::take(&mut state.cursor_redraw_pending) || wallpaper_changed || wallpaper_retry
{ {
+1
View File
@@ -123,6 +123,7 @@ impl SessionLockHandler for Ferese {
} }
self.cancel_logout_confirmation(); self.cancel_logout_confirmation();
self.session_lock.active = true; self.session_lock.active = true;
self.input_capture.disable_all();
self.portal_session.set_locked(true); self.portal_session.set_locked(true);
self.session_lock.owner = Some(confirmation.ext_session_lock().clone()); self.session_lock.owner = Some(confirmation.ext_session_lock().clone());
self.session_lock.confirmation = Some(confirmation); self.session_lock.confirmation = Some(confirmation);
+16
View File
@@ -232,6 +232,7 @@ pub struct Ferese {
pub display_handle: DisplayHandle, pub display_handle: DisplayHandle,
pub loop_signal: LoopSignal, pub loop_signal: LoopSignal,
pub(crate) loop_handle: smithay::reexports::calloop::LoopHandle<'static, Self>, 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(crate) portal_session: crate::portal_session::PortalSession,
pub space: Space<Window>, pub space: Space<Window>,
pub workspaces: WorkspaceSet, pub workspaces: WorkspaceSet,
@@ -453,6 +454,7 @@ impl Ferese {
loop_signal: event_loop.get_signal(), loop_signal: event_loop.get_signal(),
loop_handle: event_loop.handle(), loop_handle: event_loop.handle(),
portal_session: Default::default(), portal_session: Default::default(),
input_capture: Default::default(),
space: Space::default(), space: Space::default(),
workspaces: WorkspaceSet::new( workspaces: WorkspaceSet::new(
config.layout_mode, config.layout_mode,
@@ -582,6 +584,10 @@ impl Ferese {
if let Some(keyboard) = self.seat.get_keyboard() { if let Some(keyboard) = self.seat.get_keyboard() {
if keyboard_changed { 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()) let options = (!config.input_settings.xkb_options.is_empty())
.then(|| config.input_settings.xkb_options.join(",")); .then(|| config.input_settings.xkb_options.join(","));
@@ -896,6 +902,9 @@ impl Ferese {
if self.session_lock.active { if self.session_lock.active {
return self.lock_surface_under(position); return self.lock_surface_under(position);
} }
if self.input_capture.active() {
return None;
}
self.layer_surface_under(position, &[Layer::Overlay, Layer::Top]) self.layer_surface_under(position, &[Layer::Overlay, Layer::Top])
.map(|(_, surface, origin)| (surface, origin)) .map(|(_, surface, origin)| (surface, origin))
.or_else(|| self.window_surface_under(position)) .or_else(|| self.window_surface_under(position))
@@ -1594,6 +1603,7 @@ impl Ferese {
} }
pub fn relayout(&mut self) { pub fn relayout(&mut self) {
self.refresh_input_capture_zones();
let visible_workspaces = self.visible_workspace_ids(); let visible_workspaces = self.visible_workspace_ids();
self.prune_empty_workspaces(&visible_workspaces); self.prune_empty_workspaces(&visible_workspaces);
if self.session_lock.active { if self.session_lock.active {
@@ -2428,6 +2438,9 @@ impl Ferese {
} }
fn focus_direction_with_slide(&mut self, direction: Direction, slide: bool) { fn focus_direction_with_slide(&mut self, direction: Direction, slide: bool) {
if self.input_capture.captures(1) {
return;
}
if self.focus_overview_direction(direction) { if self.focus_overview_direction(direction) {
return; return;
} }
@@ -3007,6 +3020,9 @@ impl Ferese {
} }
pub(crate) fn restore_keyboard_focus(&mut self) { pub(crate) fn restore_keyboard_focus(&mut self) {
if self.input_capture.captures(1) {
return;
}
if self.session_lock.active { if self.session_lock.active {
self.focus_lock_surface(); self.focus_lock_surface();
return; return;
+3
View File
@@ -2863,6 +2863,9 @@ fn cursor_elements(
output_geometry: Rectangle<i32, Logical>, output_geometry: Rectangle<i32, Logical>,
scale: f64, scale: f64,
) -> Vec<AnimatedWindowRenderElement> { ) -> Vec<AnimatedWindowRenderElement> {
if state.input_capture.active() {
return Vec::new();
}
let Some(pointer) = state.seat.get_pointer() else { let Some(pointer) = state.seat.get_pointer() else {
return Vec::new(); return Vec::new();
}; };
+21 -4
View File
@@ -47,13 +47,20 @@ standard file, print and application dialogs. Install both backends.
for acknowledgements for up to one second. Power confirmations list blockers for acknowledgements for up to one second. Power confirmations list blockers
and recheck them before acting; authentication failure cancels the query. and recheck them before acting; authentication failure cancels the query.
UserSwitch is recorded, but Ferese currently has no user-switch operation. 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 - **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 saved permissions. The frontend stores and revokes saved choices. Invalid
restore data or changed display identity requires fresh consent. restore data or changed display identity requires fresh consent.
See [screen sharing](screen-sharing.md) for lifetime and recording details. See [screen sharing](screen-sharing.md) for lifetime and recording details.
Native request dialogs use Ferese's theme and are floating windows. Screenshot, 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. a transient relationship with the requesting window.
Cancelling a request or losing the portal frontend terminates its pending helper. 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 | | GlobalShortcuts | Native sessions, configurable keyboard triggers, conflict checks and activation/release signals |
| RemoteDesktop | Deferred: authorized input injection and EIS transport | | RemoteDesktop | Deferred: authorized input injection and EIS transport |
| Clipboard | Deferred with RemoteDesktop | | 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 | | 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 | | 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 resizing renegotiates PipeWire video dimensions. Closing the selected
window ends sharing; its lifetime ID cannot select a replacement app. Window window ends sharing; its lifetime ID cannot select a replacement app. Window
selections require fresh consent and are not saved as display permissions. selections require fresh consent and are not saved as display permissions.
InputCapture still needs native zones, barriers and EIS transport. RemoteDesktop RemoteDesktop and its Clipboard integration remain deferred.
and its Clipboard integration remain deferred.
## Validation ## Validation
@@ -155,3 +161,14 @@ closure using a disposable nested compositor and a real PipeWire consumer:
```sh ```sh
FERESE_TEST_WINDOW_STREAM=1 python3 scripts/tests/test_window_stream_isolated.py 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.
+1
View File
@@ -9,3 +9,4 @@ org.freedesktop.impl.portal.Usb=ferese;
org.freedesktop.impl.portal.Lockdown=ferese; org.freedesktop.impl.portal.Lockdown=ferese;
org.freedesktop.impl.portal.GlobalShortcuts=ferese; org.freedesktop.impl.portal.GlobalShortcuts=ferese;
org.freedesktop.impl.portal.Inhibit=ferese; org.freedesktop.impl.portal.Inhibit=ferese;
org.freedesktop.impl.portal.InputCapture=ferese;
+1 -1
View File
@@ -1,4 +1,4 @@
[portal] [portal]
DBusName=org.freedesktop.impl.portal.desktop.ferese 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; UseIn=Ferese;
+2
View File
@@ -14,6 +14,8 @@ name = "ferese-record"
path = "src/record_main.rs" path = "src/record_main.rs"
[dependencies] [dependencies]
reis = { version = "0.7.1", features = ["calloop"] }
calloop.workspace = true
ferese-protocols = { path = "../crates/ferese-protocols", default-features = false, features = ["client"] } ferese-protocols = { path = "../crates/ferese-protocols", default-features = false, features = ["client"] }
ferese-ipc = { path = "../crates/ferese-ipc" } ferese-ipc = { path = "../crates/ferese-ipc" }
url = "2.5" url = "2.5"
+10 -2
View File
@@ -54,12 +54,15 @@ pub(crate) struct Cancel {
impl Cancel { impl Cancel {
pub(crate) fn stop(&self) { pub(crate) fn stop(&self) {
self.stopped.store(true, Ordering::SeqCst); self.stopped.store(true, Ordering::SeqCst);
self.changed.notify_one(); self.changed.notify_waiters();
} }
pub(crate) async fn wait(&self) { pub(crate) async fn wait(&self) {
let changed = self.changed.notified();
tokio::pin!(changed);
changed.as_mut().enable();
if !self.stopped.load(Ordering::SeqCst) { if !self.stopped.load(Ordering::SeqCst) {
self.changed.notified().await; changed.await;
} }
} }
} }
@@ -912,6 +915,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let lockdown = crate::lockdown::Lockdown::new(); let lockdown = crate::lockdown::Lockdown::new();
let shortcuts = crate::shortcuts::GlobalShortcuts::new(requests.clone()); let shortcuts = crate::shortcuts::GlobalShortcuts::new(requests.clone());
let inhibit = crate::inhibit::Inhibit::default(); let inhibit = crate::inhibit::Inhibit::default();
let input_capture = crate::input_capture::InputCapture::new(requests.clone());
let connection = zbus::connection::Builder::session()? let connection = zbus::connection::Builder::session()?
.name("org.freedesktop.impl.portal.desktop.ferese")? .name("org.freedesktop.impl.portal.desktop.ferese")?
.serve_at(PATH, backend.clone())? .serve_at(PATH, backend.clone())?
@@ -923,6 +927,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
.serve_at(PATH, lockdown.clone())? .serve_at(PATH, lockdown.clone())?
.serve_at(PATH, shortcuts.clone())? .serve_at(PATH, shortcuts.clone())?
.serve_at(PATH, inhibit.clone())? .serve_at(PATH, inhibit.clone())?
.serve_at(PATH, input_capture.clone())?
.serve_at( .serve_at(
"/org/ferese/ScreenRecorder", "/org/ferese/ScreenRecorder",
RecorderControl(backend.clone()), RecorderControl(backend.clone()),
@@ -950,6 +955,9 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
requests.revoke_stale(owner.as_deref()).await; requests.revoke_stale(owner.as_deref()).await;
shortcuts.revoke_stale(&connection, owner.as_deref()).await; shortcuts.revoke_stale(&connection, owner.as_deref()).await;
inhibit.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 let stale: Vec<_> = backend
.sessions .sessions
+9
View File
@@ -72,6 +72,15 @@ impl Bridge {
.map_err(|error| error.to_string())? .map_err(|error| error.to_string())?
} }
pub(crate) async fn capture_watch(&self, session: u64) -> Result<Json, String> {
self.request(
"input-capture-watch",
serde_json::json!({"session":session}),
true,
)
.await
}
pub(crate) async fn closed(&self) { pub(crate) async fn closed(&self) {
use std::io::Read; use std::io::Read;
let Ok(reader) = self.0.shutdown.try_clone() else { let Ok(reader) = self.0.shutdown.try_clone() else {
+471
View File
@@ -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<Vec<Value>>,
signal: LoopSignal,
pub(crate) ready: Arc<AtomicBool>,
}
impl Worker {
pub(crate) fn start(
capabilities: u32,
keymap: &str,
cancel: Arc<Cancel>,
) -> 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<Cancel>);
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::<State>::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<Value>) -> 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<AtomicBool>,
capabilities: u32,
keymap: File,
keymap_size: u32,
connection: Option<Connection>,
keyboard: Option<Device>,
pointer: Option<Device>,
activation: Option<u32>,
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::<DeviceCapability>::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::<eis::Keyboard>() {
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<Value>) {
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::<eis::Keyboard>()
{
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::<eis::Keyboard>()
{
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::<eis::Pointer>() {
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::<eis::Button>() {
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::<eis::Scroll>() {
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"
);
}
}
+784
View File
@@ -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<Bridge>,
watch: Option<Bridge>,
worker: Option<Worker>,
}
struct Session {
owner: String,
app: String,
cancel: Arc<Cancel>,
state: Mutex<State>,
}
#[derive(Clone, Default)]
pub(crate) struct InputCapture {
sessions: Arc<Mutex<HashMap<String, Arc<Session>>>>,
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<Arc<Session>> {
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::<SessionObject, _>(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::<Vec<_>>();
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<Session>,
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<u32> {
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<Options> {
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<Options>,
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::<zbus::fdo::Result<Vec<_>>>()?;
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<u32> = 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<OwnedFd> {
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
);
}
}
}
+2
View File
@@ -3,7 +3,9 @@ mod bridge;
mod capture; mod capture;
mod consent; mod consent;
mod desktop; mod desktop;
mod eis;
mod inhibit; mod inhibit;
mod input_capture;
mod lockdown; mod lockdown;
mod parent; mod parent;
mod permissions; mod permissions;
+77
View File
@@ -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)
+13 -1
View File
@@ -54,7 +54,7 @@ def private_bus_checks():
raise AssertionError((root / "backend.log").read_text()) raise AssertionError((root / "backend.log").read_text())
time.sleep(0.05) time.sleep(0.05)
node = ET.fromstring(xml) 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}']") interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']")
assert interface is not None, 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") 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)"), GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), GLib.VariantType.new("(u)"),
Gio.DBusCallFlags.NONE, 5000, None).unpack()[0] Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
assert request_name == 1 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)") version, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.ScreenCast", "version")), "(v)")
assert version == 4 assert version == 4
source_types, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.ScreenCast", "AvailableSourceTypes")), "(v)") source_types, = call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.ScreenCast", "AvailableSourceTypes")), "(v)")