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:
1 parent
baca852ee9
commit
d26e1511d1
22 files changed
+2349
-11
No files matched your search
Generated
+14
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<f64, Logical>, 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();
|
||||
|
||||
@@ -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
@@ -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<Response>,
|
||||
@@ -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
|
||||
|
||||
@@ -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<dyn Error>> {
|
||||
}
|
||||
// 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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Window>,
|
||||
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;
|
||||
|
||||
@@ -2863,6 +2863,9 @@ fn cursor_elements(
|
||||
output_geometry: Rectangle<i32, Logical>,
|
||||
scale: f64,
|
||||
) -> Vec<AnimatedWindowRenderElement> {
|
||||
if state.input_capture.active() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(pointer) = state.seat.get_pointer() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
+21
-4
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
+10
-2
@@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
.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<dyn std::error::Error>> {
|
||||
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
|
||||
|
||||
@@ -72,6 +72,15 @@ impl Bridge {
|
||||
.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) {
|
||||
use std::io::Read;
|
||||
let Ok(reader) = self.0.shutdown.try_clone() else {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+77
@@ -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)
|
||||
@@ -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)")
|
||||
|
||||
Reference in new issue
Block a user