diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 49bd602..cfd171f 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -485,6 +485,64 @@ pub enum BindingAction { } impl Binding { + pub(crate) fn portal_trigger(chord: &str) -> Result { + if chord.len() > 256 { + return Err(ConfigError::InvalidBinding("Shortcut is too long".into())); + } + let parts = chord.split('+').collect::>(); + if parts + .iter() + .filter(|part| part.trim().eq_ignore_ascii_case("num")) + .count() + > 1 + { + return Err(ConfigError::InvalidBinding("Duplicate NUM modifier".into())); + } + let chord = parts + .into_iter() + .filter(|part| !part.trim().eq_ignore_ascii_case("num")) + .collect::>() + .join("+"); + let (modifiers, key) = parse_chord(&chord)?; + let symbol = parse_keysym(&key)?; + if (modifiers.ctrl + && modifiers.alt + && matches!( + symbol, + keysyms::KEY_Escape | keysyms::KEY_F1..=keysyms::KEY_F12 + )) + || (keysyms::KEY_XF86Switch_VT_1..=keysyms::KEY_XF86Switch_VT_12).contains(&symbol) + { + return Err(ConfigError::InvalidBinding( + "Reserved system shortcut".into(), + )); + } + Ok(Self { + modifiers, + trigger: BindingTrigger::Keysym(symbol), + action: BindingAction::None, + }) + } + + pub(crate) fn conflicts(&self, other: &Self, map: &xkb::Keymap) -> bool { + if self.modifiers != other.modifiers { + return false; + } + match (&self.trigger, &other.trigger) { + (BindingTrigger::Keysym(left), BindingTrigger::Keysym(right)) => left == right, + (BindingTrigger::Keysym(symbol), BindingTrigger::Physical(code)) + | (BindingTrigger::Physical(code), BindingTrigger::Keysym(symbol)) => { + (0..map.num_layouts_for_key(*code)).any(|layout| { + map.key_get_syms_by_level(*code, layout, 0) + .iter() + .any(|key| key.raw() == *symbol) + }) + } + (BindingTrigger::Physical(left), BindingTrigger::Physical(right)) => left == right, + _ => false, + } + } + pub fn matches( &self, keycode: Keycode, @@ -1197,7 +1255,7 @@ fn validate_commands(commands: &HashMap>) -> Result<(), Conf Ok(()) } -fn physical_keymap(input: &InputSettings) -> Result { +pub(crate) fn physical_keymap(input: &InputSettings) -> Result { let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS); let options = (!input.xkb_options.is_empty()).then(|| input.xkb_options.join(",")); diff --git a/compositor/src/input.rs b/compositor/src/input.rs index eb3fc09..f95e2a8 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -150,13 +150,15 @@ impl Ferese { SERIAL_COUNTER.next_serial(), Event::time(&event) as u32, |data, modifiers, keysym| { + if state == KeyState::Released { + data.portal_shortcuts.release(keycode, Event::time(&event)); + if data.intercepted_keys.remove(&keycode) { + return FilterResult::Intercept(()); + } + } if data.session_lock.active { return FilterResult::Forward; } - if state == KeyState::Released && data.intercepted_keys.remove(&keycode) { - return FilterResult::Intercept(()); - } - let symbol = keysym.modified_sym().raw(); if data.overview.is_active() && matches!(symbol, keysyms::KEY_Return | keysyms::KEY_KP_Enter) @@ -225,6 +227,17 @@ impl Ferese { .then(|| binding.action.clone()) }); let Some(action) = action else { + if state == KeyState::Pressed + && data.portal_shortcuts.press( + keycode, + &raw_symbols, + modifiers, + Event::time(&event), + ) + { + data.intercepted_keys.insert(keycode); + return FilterResult::Intercept(()); + } return FilterResult::Forward; }; diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 57bb1e8..1a4323a 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -5,7 +5,7 @@ use std::os::fd::AsRawFd; use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{ Arc, mpsc::{SyncSender, sync_channel}, @@ -47,10 +47,28 @@ const DEADLINE_IDLE: Duration = Duration::from_secs(1); #[derive(Debug)] struct IpcCall { + owner: u64, request: Request, response: SyncSender, } +#[derive(Debug)] +enum IpcEvent { + Call(IpcCall), + Closed(u64), +} + +struct IpcConnection { + owner: u64, + sender: channel::SyncSender, +} + +impl Drop for IpcConnection { + fn drop(&mut self) { + let _ = self.sender.send(IpcEvent::Closed(self.owner)); + } +} + struct ConnectionPermit { active: Arc, } @@ -94,18 +112,25 @@ pub(crate) fn init( inode: metadata.ino(), }; - let (sender, receiver): (channel::SyncSender, channel::Channel) = + let (sender, receiver): (channel::SyncSender, channel::Channel) = channel::sync_channel(REQUEST_QUEUE_CAPACITY); event_loop .handle() .insert_source(receiver, |event, _, state| { - if let channel::Event::Msg(call) = event { + if let channel::Event::Msg(event) = event { + let call = match event { + IpcEvent::Call(call) => call, + IpcEvent::Closed(owner) => { + state.portal_shortcuts.remove(owner); + return; + } + }; if call.request.command == "screenshot" { // Deferred: this path answers the caller itself, exactly // once, whenever the request finishes or is terminated. state.start_screenshot(call.request, call.response); } else { - let response = state.handle_ipc_request(call.request); + let response = state.handle_ipc_request(call.owner, call.request); let _ = call.response.send(response); } } @@ -187,10 +212,11 @@ pub(crate) struct ScreenshotInit { fn accept_connections( listener: UnixListener, - sender: channel::SyncSender, + sender: channel::SyncSender, signal: LoopSignal, ) { let active_connections = Arc::new(AtomicUsize::new(0)); + let connection_ids = AtomicU64::new(1); loop { let (stream, _) = match listener.accept() { @@ -222,12 +248,13 @@ fn accept_connections( continue; }; let sender = sender.clone(); + let owner = connection_ids.fetch_add(1, Ordering::Relaxed); let signal = signal.clone(); if let Err(error) = thread::Builder::new() .name("ferese-ipc-client".to_owned()) .spawn(move || { let _permit = permit; - serve_connection(stream, sender, signal); + serve_connection(stream, sender, signal, owner); }) { tracing::warn!(%error, "could not start IPC connection worker"); @@ -254,9 +281,14 @@ fn try_acquire_connection(active: &Arc) -> Option fn serve_connection( mut stream: UnixStream, - sender: channel::SyncSender, + sender: channel::SyncSender, signal: LoopSignal, + owner: u64, ) { + let _connection = IpcConnection { + owner, + sender: sender.clone(), + }; loop { let request: Request = match read_frame(&mut stream) { Ok(request) => request, @@ -277,7 +309,11 @@ fn serve_connection( }; let (response, receiver) = sync_channel(1); let exit_requested = request.command == "exit"; - let call = IpcCall { request, response }; + let call = IpcEvent::Call(IpcCall { + owner, + request, + response, + }); if sender.try_send(call).is_err() { tracing::warn!("disconnecting IPC client because the request queue is full"); return; @@ -300,8 +336,8 @@ fn serve_connection( } impl Ferese { - fn handle_ipc_request(&mut self, request: Request) -> Response { - if self.session_lock.active { + fn handle_ipc_request(&mut self, owner: u64, request: Request) -> Response { + if self.session_lock.active && request.command != "portal-shortcuts-poll" { return Response::error( request.id, "session_locked", @@ -312,14 +348,30 @@ impl Ferese { return Response::error(request.id, error.code, error.message); } - match self.dispatch_ipc_command(&request.command, &request.args) { + match self.dispatch_ipc_command(owner, &request.command, &request.args) { Ok(result) => Response::success(request.id, result), Err(error) => Response::error(request.id, error.code, error.message), } } - fn dispatch_ipc_command(&mut self, command: &str, args: &Value) -> Result { + fn dispatch_ipc_command( + &mut self, + owner: u64, + command: &str, + args: &Value, + ) -> Result { match command { + "portal-shortcuts-register" => { + let shortcuts = serde_json::from_value(args.clone()) + .map_err(|error| CommandError::new("invalid_argument", error.to_string()))?; + self.portal_shortcuts + .register(owner, shortcuts, &self.bindings, &self.input_settings) + .map_err(|error| CommandError::new("shortcut_conflict", error))?; + return Ok(json!({})); + } + "portal-shortcuts-poll" => { + return Ok(self.portal_shortcuts.poll(owner, self.session_lock.active)); + } "exit" => {} // The IPC worker stops the loop after writing the response. "request-logout" => self.request_logout_confirmation(), "reload-config" => self @@ -961,7 +1013,7 @@ mod tests { let (sender, _receiver) = channel::sync_channel(1); let event_loop = EventLoop::<()>::try_new().unwrap(); let signal = event_loop.get_signal(); - let worker = thread::spawn(move || serve_connection(server, sender, signal)); + let worker = thread::spawn(move || serve_connection(server, sender, signal, 1)); let invalid = b"not-json"; client diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 3ab7bc3..cc6b5f7 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -13,6 +13,7 @@ mod input; mod ipc; mod metrics; mod overview; +mod portal_shortcuts; mod presentation; mod private_client; mod reload; diff --git a/compositor/src/portal_shortcuts.rs b/compositor/src/portal_shortcuts.rs new file mode 100644 index 0000000..e5ef346 --- /dev/null +++ b/compositor/src/portal_shortcuts.rs @@ -0,0 +1,360 @@ +use std::{ + collections::{HashMap, VecDeque}, + time::Instant, +}; + +use serde::Deserialize; +use serde_json::{Value, json}; +use smithay::input::keyboard::{Keycode, ModifiersState}; + +use crate::config::{Binding, InputSettings}; + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Shortcut { + pub id: String, + pub trigger: String, + #[serde(skip)] + num_required: bool, +} + +struct Registration { + shortcuts: Vec<(Shortcut, Binding)>, + held: HashMap, + events: VecDeque, + latest: (u64, Instant), + closed: bool, +} + +impl Registration { + fn timestamp(&self) -> u64 { + self.latest + .0 + .saturating_add(self.latest.1.elapsed().as_millis() as u64) + } + + fn release_all(&mut self) { + let timestamp = self.timestamp(); + for id in self.held.drain().map(|(_, id)| id) { + self.events + .push_back(json!({"id":id,"active":false,"timestamp":timestamp})); + } + } +} + +#[derive(Default)] +pub(crate) struct PortalShortcuts(HashMap); + +impl PortalShortcuts { + pub(crate) fn register( + &mut self, + owner: u64, + shortcuts: Vec, + configured: &[Binding], + input: &InputSettings, + ) -> Result<(), String> { + if shortcuts.len() > 64 || (!self.0.contains_key(&owner) && self.0.len() >= 16) { + return Err("Shortcut limit reached".into()); + } + let keymap = crate::config::physical_keymap(input).map_err(|error| error.to_string())?; + if self + .0 + .get(&owner) + .is_some_and(|registration| registration.closed) + { + return Err("Shortcut session has ended".into()); + } + let mut parsed: Vec<(Shortcut, Binding)> = Vec::new(); + for mut shortcut in shortcuts { + shortcut.num_required = shortcut + .trigger + .split('+') + .any(|part| part.trim().eq_ignore_ascii_case("num")); + if shortcut.id.is_empty() + || shortcut.id.len() > 256 + || shortcut.id.chars().any(char::is_control) + { + return Err("Invalid shortcut identifier".into()); + } + let binding = + Binding::portal_trigger(&shortcut.trigger).map_err(|error| error.to_string())?; + if configured + .iter() + .any(|other| binding.conflicts(other, &keymap)) + || parsed + .iter() + .any(|(other, key)| other.id == shortcut.id || binding.conflicts(key, &keymap)) + || self.0.iter().any(|(connection, registration)| { + *connection != owner + && registration + .shortcuts + .iter() + .any(|(_, other)| binding.conflicts(other, &keymap)) + }) + { + return Err(format!("Shortcut {} is already in use", shortcut.trigger)); + } + parsed.push((shortcut, binding)); + } + let registration = self.0.entry(owner).or_insert_with(|| Registration { + shortcuts: Vec::new(), + held: HashMap::new(), + events: VecDeque::new(), + latest: (0, Instant::now()), + closed: false, + }); + registration.release_all(); + registration.shortcuts = parsed; + Ok(()) + } + + pub(crate) fn remove(&mut self, owner: u64) { + self.0.remove(&owner); + } + + pub(crate) fn reconcile(&mut self, configured: &[Binding], input: &InputSettings) { + let Ok(keymap) = crate::config::physical_keymap(input) else { + for registration in self.0.values_mut() { + registration.closed = true; + } + return; + }; + for registration in self.0.values_mut() { + registration.shortcuts.retain(|(_, binding)| { + !configured.iter().any(|key| binding.conflicts(key, &keymap)) + }); + let active_ids = registration + .shortcuts + .iter() + .map(|(shortcut, _)| shortcut.id.as_str()) + .collect::>(); + let timestamp = registration.timestamp(); + registration.held.retain(|_, id| { + if active_ids.contains(&id.as_str()) { + true + } else { + registration + .events + .push_back(json!({"id":id,"active":false,"timestamp":timestamp})); + false + } + }); + } + } + + pub(crate) fn poll(&mut self, owner: u64, locked: bool) -> Value { + let Some(registration) = self.0.get_mut(&owner) else { + return json!({"closed":true}); + }; + if registration.closed { + return json!({"closed":true}); + } + if locked { + registration.release_all(); + } + json!({ + "shortcuts": registration.shortcuts.iter().map(|(shortcut, _)| json!({"id":shortcut.id,"trigger":shortcut.trigger})).collect::>(), + "events": registration.events.drain(..).collect::>() + }) + } + + pub(crate) fn press( + &mut self, + key: Keycode, + symbols: &[u32], + modifiers: &ModifiersState, + timestamp: u64, + ) -> bool { + for registration in self.0.values_mut() { + let Some((shortcut, _)) = registration.shortcuts.iter().find(|(shortcut, binding)| { + (!shortcut.num_required || modifiers.num_lock) + && binding.matches( + key, + symbols, + modifiers.logo, + modifiers.ctrl, + modifiers.alt, + modifiers.shift, + ) + }) else { + continue; + }; + if !registration.held.contains_key(&key) { + if registration.events.len() >= 256 { + registration.closed = true; + registration.shortcuts.clear(); + registration.held.clear(); + registration.events.clear(); + return false; + } + registration.latest = (timestamp, Instant::now()); + registration.held.insert(key, shortcut.id.clone()); + registration + .events + .push_back(json!({"id":shortcut.id,"active":true,"timestamp":timestamp})); + } + return true; + } + false + } + + pub(crate) fn release(&mut self, key: Keycode, timestamp: u64) { + for registration in self.0.values_mut() { + if let Some(id) = registration.held.remove(&key) { + registration.latest = (timestamp, Instant::now()); + registration + .events + .push_back(json!({"id":id,"active":false,"timestamp":timestamp})); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use smithay::input::keyboard::keysyms; + + fn shortcut(id: &str, trigger: &str) -> Shortcut { + Shortcut { + id: id.into(), + trigger: trigger.into(), + num_required: false, + } + } + + #[test] + fn registrations_are_atomic_and_connection_scoped() { + let input = crate::config::Config::parse_source("") + .unwrap() + .input_settings() + .unwrap(); + let configured = vec![Binding::portal_trigger("Super+q").unwrap()]; + let mut shortcuts = PortalShortcuts::default(); + shortcuts + .register(1, vec![shortcut("one", "Ctrl+Alt+a")], &configured, &input) + .unwrap(); + assert!( + shortcuts + .register(2, vec![shortcut("two", "Ctrl+Alt+a")], &configured, &input) + .is_err() + ); + assert!( + shortcuts + .register(1, vec![shortcut("one", "Super+q")], &configured, &input) + .is_err() + ); + assert_eq!( + shortcuts.poll(1, false)["shortcuts"][0]["trigger"], + "Ctrl+Alt+a" + ); + shortcuts.remove(1); + assert_eq!(shortcuts.poll(1, false)["closed"], true); + shortcuts + .register(2, vec![shortcut("two", "Ctrl+Alt+a")], &configured, &input) + .unwrap(); + } + + #[test] + fn releases_follow_the_pressed_key_and_lock_releases_held_shortcuts() { + let mut shortcuts = PortalShortcuts::default(); + shortcuts + .register( + 1, + vec![shortcut("one", "Ctrl+Alt+a")], + &[], + &crate::config::Config::parse_source("") + .unwrap() + .input_settings() + .unwrap(), + ) + .unwrap(); + let key = Keycode::new(38); + let modifiers = ModifiersState { + ctrl: true, + alt: true, + ..ModifiersState::default() + }; + assert!(shortcuts.press(key, &[keysyms::KEY_a], &modifiers, 10)); + assert!(shortcuts.press(key, &[keysyms::KEY_a], &modifiers, 11)); + shortcuts.release(key, 12); + let result = shortcuts.poll(1, false); + assert_eq!(result["events"].as_array().unwrap().len(), 2); + assert_eq!(result["events"][1]["active"], false); + assert!(shortcuts.press(key, &[keysyms::KEY_a], &modifiers, 13)); + assert_eq!(shortcuts.poll(1, true)["events"][1]["active"], false); + } + + #[test] + fn configuration_changes_revoke_conflicting_shortcuts() { + let mut shortcuts = PortalShortcuts::default(); + let input = crate::config::Config::parse_source("") + .unwrap() + .input_settings() + .unwrap(); + shortcuts + .register(1, vec![shortcut("one", "Ctrl+Alt+a")], &[], &input) + .unwrap(); + shortcuts.reconcile(&[Binding::portal_trigger("Ctrl+Alt+a").unwrap()], &input); + assert!( + shortcuts.poll(1, false)["shortcuts"] + .as_array() + .unwrap() + .is_empty() + ); + assert!(Binding::portal_trigger("Ctrl+Alt+Escape").is_err()); + assert!(Binding::portal_trigger("Ctrl+Alt+F3").is_err()); + } + + #[test] + fn queue_overflow_closes_the_session_instead_of_leaving_held_keys() { + let mut shortcuts = PortalShortcuts::default(); + let input = crate::config::Config::parse_source("") + .unwrap() + .input_settings() + .unwrap(); + shortcuts + .register(1, vec![shortcut("one", "Ctrl+Alt+a")], &[], &input) + .unwrap(); + let key = Keycode::new(38); + let modifiers = ModifiersState { + ctrl: true, + alt: true, + ..ModifiersState::default() + }; + for timestamp in 0..200 { + shortcuts.press(key, &[keysyms::KEY_a], &modifiers, timestamp * 2); + shortcuts.release(key, timestamp * 2 + 1); + } + assert_eq!(shortcuts.poll(1, false)["closed"], true); + assert!(!shortcuts.press(key, &[keysyms::KEY_a], &modifiers, 500)); + } + + #[test] + fn physical_conflicts_include_secondary_layouts_and_num_modifier_is_honored() { + let config = crate::config::Config::parse_source( + "binding keys=\"Ctrl+AD01\" match=\"physical\" action=\"none\"\n", + ) + .unwrap(); + let mut input = config.input_settings().unwrap(); + input.xkb_layout = "us,fr".into(); + let configured = config.bindings(&input).unwrap(); + let mut shortcuts = PortalShortcuts::default(); + assert!( + shortcuts + .register(1, vec![shortcut("one", "Ctrl+a")], &configured, &input) + .is_err() + ); + shortcuts + .register(1, vec![shortcut("one", "NUM+Ctrl+z")], &[], &input) + .unwrap(); + let key = Keycode::new(52); + let mut modifiers = ModifiersState { + ctrl: true, + ..ModifiersState::default() + }; + assert!(!shortcuts.press(key, &[keysyms::KEY_z], &modifiers, 1)); + modifiers.num_lock = true; + assert!(shortcuts.press(key, &[keysyms::KEY_z], &modifiers, 2)); + } +} diff --git a/compositor/src/state.rs b/compositor/src/state.rs index ad9687c..0b9aa2f 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -257,6 +257,7 @@ pub struct Ferese { pub(crate) overview_scrims: HashMap, pub(crate) material_programs: HashMap, pub(crate) material_buffers: HashMap, + pub(crate) portal_shortcuts: crate::portal_shortcuts::PortalShortcuts, pub(crate) pending_logout: Option, pub(crate) logout_owner: Option, pub(crate) blur_programs: HashMap, @@ -480,6 +481,7 @@ impl Ferese { overview_scrims: HashMap::new(), material_programs: HashMap::new(), material_buffers: HashMap::new(), + portal_shortcuts: crate::portal_shortcuts::PortalShortcuts::default(), pending_logout: None, logout_owner: None, blur_programs: HashMap::new(), @@ -626,6 +628,8 @@ impl Ferese { self.gap_config = config.gap_config; self.input_settings = config.input_settings; self.bindings = config.bindings; + self.portal_shortcuts + .reconcile(&self.bindings, &self.input_settings); let old_rules = std::mem::replace(&mut self.window_rules, config.window_rules); self.theme_settings = config.theme_settings; self.column_width_presets = config.column_width_presets; diff --git a/docs/portals.md b/docs/portals.md index 77166b8..752dc9e 100644 --- a/docs/portals.md +++ b/docs/portals.md @@ -31,6 +31,12 @@ standard file, print and application dialogs. Install both backends. policy. Only the portal frontend may write backend policy properties. - **USB:** native confirmation showing device vendor/model and requested read-only or read/write access. The portal frontend handles device enumeration and opening. +- **GlobalShortcuts:** session-scoped keyboard shortcuts with native trigger + selection, conflict checking, activation/release signals and reconfiguration. + Applications cannot replace Ferese bindings or reserved escape/VT shortcuts. + Closing a session or losing its compositor connection releases its shortcuts; + shortcuts do not activate on the lock screen. Saved choices are offered for + future sessions, with explicit approval before registering them. - **ScreenCast:** monitor sharing with a native picker and PipeWire streams. See [screen sharing](screen-sharing.md) for lifetime and recording details. @@ -59,7 +65,7 @@ Matching D-Bus signatures does not establish complete feature parity. | Access, Account, AppChooser, DynamicLauncher, FileChooser, Notification, Print | Delegated to GTK | | Background | Native application state, per-instance consent and XDG autostart | | Usb | Native consent; standard frontend handles enumeration and device descriptors | -| GlobalShortcuts | Missing session-scoped compositor registration and activation signals | +| GlobalShortcuts | Native sessions, configurable keyboard triggers, conflict checks and activation/release signals | | RemoteDesktop | Missing authorized input injection and EIS transport | | Clipboard | Missing remote-session clipboard transfer | | InputCapture | Missing zones, pointer barriers and EIS transport | @@ -85,6 +91,13 @@ cargo test --locked -p xdg-desktop-portal-ferese python3 scripts/tests/test_portal_contracts.py ``` +With a Wayland host and a built compositor, check shortcut connection cleanup +and configuration conflicts in an isolated nested compositor: + +```sh +FERESE_TEST_SHORTCUTS=1 python3 scripts/tests/test_shortcuts_isolated.py +``` + The contract test compares introspection against the installed official backend XML, verifies appearance changes and invalid-save retention, and checks that sensitive methods reject callers outside the portal frontend. It does not open diff --git a/packaging/portal/ferese-portals.conf b/packaging/portal/ferese-portals.conf index 00e588e..4618f86 100644 --- a/packaging/portal/ferese-portals.conf +++ b/packaging/portal/ferese-portals.conf @@ -7,3 +7,4 @@ org.freedesktop.impl.portal.Wallpaper=ferese; org.freedesktop.impl.portal.Background=ferese; org.freedesktop.impl.portal.Usb=ferese; org.freedesktop.impl.portal.Lockdown=ferese; +org.freedesktop.impl.portal.GlobalShortcuts=ferese; diff --git a/packaging/portal/ferese.portal b/packaging/portal/ferese.portal index c0c0114..79c0af6 100644 --- a/packaging/portal/ferese.portal +++ b/packaging/portal/ferese.portal @@ -1,4 +1,4 @@ [portal] DBusName=org.freedesktop.impl.portal.desktop.ferese -Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Usb;org.freedesktop.impl.portal.Lockdown; +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; UseIn=Ferese; diff --git a/portal/src/backend.rs b/portal/src/backend.rs index 1674f67..012943a 100644 --- a/portal/src/backend.rs +++ b/portal/src/backend.rs @@ -668,6 +668,7 @@ pub async fn run() -> Result<(), Box> { let requests = crate::desktop::Requests::default(); let background = crate::permissions::Background::new(requests.clone()); let lockdown = crate::lockdown::Lockdown::new(); + let shortcuts = crate::shortcuts::GlobalShortcuts::new(requests.clone()); let connection = zbus::connection::Builder::session()? .name("org.freedesktop.impl.portal.desktop.ferese")? .serve_at(PATH, backend.clone())? @@ -677,6 +678,7 @@ pub async fn run() -> Result<(), Box> { .serve_at(PATH, crate::permissions::Usb(requests.clone()))? .serve_at(PATH, background.clone())? .serve_at(PATH, lockdown.clone())? + .serve_at(PATH, shortcuts.clone())? .serve_at( "/org/ferese/ScreenRecorder", RecorderControl(backend.clone()), @@ -698,6 +700,7 @@ pub async fn run() -> Result<(), Box> { Err(_) => None, }; requests.revoke_stale(owner.as_deref()).await; + shortcuts.revoke_stale(&connection, owner.as_deref()).await; let stale: Vec<_> = backend .sessions .lock() diff --git a/portal/src/consent.rs b/portal/src/consent.rs index 0dd3ccc..1f38c44 100644 --- a/portal/src/consent.rs +++ b/portal/src/consent.rs @@ -2,7 +2,7 @@ use cosmic::{ Element, app::{Core, Settings, Task}, iced::{Alignment, Length}, - widget::{button, column, container, image, row, scrollable}, + widget::{button, column, container, image, row, scrollable, text_input}, }; use serde::{Deserialize, Serialize}; use std::io::{Read, Write}; @@ -14,6 +14,15 @@ pub(crate) struct Prompt { pub accept: String, pub parent: String, pub image: Option, + #[serde(default)] + pub shortcuts: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct ShortcutField { + pub id: String, + pub description: String, + pub trigger: String, } #[derive(Clone, Debug)] @@ -30,6 +39,7 @@ enum Message { ), Accept, Cancel, + Shortcut(usize, String), } struct Consent { @@ -45,11 +55,18 @@ struct Consent { pub(crate) fn run() -> Result<(), Box> { let mut input = String::new(); std::io::stdin() - .take(64 * 1024) + .take(256 * 1024 + 1) .read_to_string(&mut input)?; + if input.len() > 256 * 1024 { + return Err("Consent request is too large".into()); + } let prompt: Prompt = serde_json::from_str(&input)?; let (theme, font, background, _) = crate::picker::appearance(); - let height = if prompt.image.is_some() { 430. } else { 220. }; + let height = if prompt.image.is_some() || !prompt.shortcuts.is_empty() { + 430. + } else { + 220. + }; cosmic::app::run::( Settings::default() .size(cosmic::iced::Size::new(440., height)) @@ -73,6 +90,7 @@ impl cosmic::Application for Consent { fn core(&self) -> &Core { &self.core } + fn core_mut(&mut self) -> &mut Core { &mut self.core } @@ -134,11 +152,20 @@ impl cosmic::Application for Consent { return cosmic::iced::exit(); } Message::Accept => { - println!("true"); + if self.prompt.shortcuts.is_empty() { + println!("true"); + } else { + println!("{}", serde_json::to_string(&self.prompt.shortcuts).unwrap()); + } let _ = std::io::stdout().flush(); return cosmic::iced::exit(); } Message::Cancel => return cosmic::iced::exit(), + Message::Shortcut(index, value) => { + if let Some(shortcut) = self.prompt.shortcuts.get_mut(index) { + shortcut.trigger = value; + } + } } Task::none() } @@ -153,9 +180,22 @@ impl cosmic::Application for Consent { fn view(&self) -> Element<'_, Message> { let text = |value: String| ferese_theme::text(value, self.font); + let mut details = column![text(self.prompt.description.clone())].spacing(12); + for (index, shortcut) in self.prompt.shortcuts.iter().enumerate() { + details = details.push( + column![ + text(shortcut.description.clone()), + text_input("Ctrl+Alt+k (leave empty to disable)", &shortcut.trigger) + .font(self.font) + .style(ferese_theme::controls::settings_input(self.palette)) + .on_input(move |value| Message::Shortcut(index, value)) + ] + .spacing(6), + ); + } let mut content = column![ text(self.prompt.title.clone()).size(22), - scrollable(text(self.prompt.description.clone())).height(Length::Fill) + scrollable(details).height(Length::Fill) ] .spacing(16); if let Some(path) = &self.prompt.image { diff --git a/portal/src/desktop.rs b/portal/src/desktop.rs index 3325b2e..37e7e1d 100644 --- a/portal/src/desktop.rs +++ b/portal/src/desktop.rs @@ -192,6 +192,7 @@ pub(crate) async fn consent( }, accept: accept.into(), image, + shortcuts: Vec::new(), }; let mut child = crate::backend::child_command()? .arg("--consent") diff --git a/portal/src/main.rs b/portal/src/main.rs index e3e9f30..aa81bd6 100644 --- a/portal/src/main.rs +++ b/portal/src/main.rs @@ -7,6 +7,7 @@ mod parent; mod permissions; mod picker; mod settings; +mod shortcuts; mod stream; fn main() -> Result<(), Box> { diff --git a/portal/src/shortcuts.rs b/portal/src/shortcuts.rs new file mode 100644 index 0000000..4891406 --- /dev/null +++ b/portal/src/shortcuts.rs @@ -0,0 +1,803 @@ +use std::{ + collections::HashMap, io::Read, os::unix::net::UnixStream, process::Stdio, sync::Arc, + time::Duration, +}; + +use serde_json::{Value as Json, json}; +use tokio::{io::AsyncWriteExt, sync::Mutex}; +use zbus::{ + Connection, + message::Header, + object_server::SignalEmitter, + zvariant::{OwnedObjectPath, OwnedValue, Value}, +}; + +use crate::{ + backend::{Cancel, Options, authorize}, + consent::{Prompt, ShortcutField}, + desktop::Requests, +}; + +const PATH: &str = "/org/freedesktop/portal/desktop"; +type Shortcuts = Vec<(String, Options)>; + +#[derive(Clone)] +struct Bridge(Arc); + +struct BridgeInner { + stream: std::sync::Mutex, + shutdown: UnixStream, +} + +impl Bridge { + fn connect() -> Result { + let path = std::env::var_os("XDG_RUNTIME_DIR") + .map(std::path::PathBuf::from) + .ok_or("Missing runtime directory")? + .join("ferese/control.sock"); + let stream = UnixStream::connect(path).map_err(|error| error.to_string())?; + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .map_err(|error| error.to_string())?; + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .map_err(|error| error.to_string())?; + let shutdown = stream.try_clone().map_err(|error| error.to_string())?; + Ok(Self(Arc::new(BridgeInner { + stream: std::sync::Mutex::new(stream), + shutdown, + }))) + } + + async fn call(&self, command: &'static str, args: Json) -> Result { + let bridge = self.clone(); + tokio::task::spawn_blocking(move || { + let mut stream = bridge + .0 + .stream + .lock() + .map_err(|_| "Shortcut connection failed")?; + let request = ferese_ipc::Request { + version: ferese_ipc::VERSION, + id: 1, + kind: "command".into(), + command: command.into(), + args, + }; + ferese_ipc::write_frame(&mut *stream, &request).map_err(|error| error.to_string())?; + let response: ferese_ipc::Response = + ferese_ipc::read_frame(&mut *stream).map_err(|error| error.to_string())?; + if let Some(error) = response.error { + return Err(error.message); + } + response.result.ok_or("Missing shortcut response".into()) + }) + .await + .map_err(|error| error.to_string())? + } + + fn close(&self) { + let _ = self.0.shutdown.shutdown(std::net::Shutdown::Both); + } +} + +struct Session { + owner: String, + app: String, + cancel: Arc, + state: Mutex, +} + +struct SessionState { + fields: Vec, + bound: bool, + busy: bool, + bridge: Option, + revision: u64, +} + +#[derive(Clone, Default)] +pub(crate) struct GlobalShortcuts { + sessions: Arc>>>, + requests: Requests, +} + +impl GlobalShortcuts { + pub(crate) fn new(requests: Requests) -> Self { + Self { + requests, + ..Self::default() + } + } + + async fn session( + &self, + connection: &Connection, + header: &Header<'_>, + path: &OwnedObjectPath, + ) -> zbus::fdo::Result> { + let owner = authorize(connection, header).await?; + self.sessions + .lock() + .await + .get(path.as_str()) + .filter(|session| session.owner == owner) + .cloned() + .ok_or_else(|| zbus::fdo::Error::AccessDenied("Not your shortcut session".into())) + } + + pub(crate) async fn revoke_stale(&self, connection: &Connection, owner: Option<&str>) { + let paths = self + .sessions + .lock() + .await + .iter() + .filter(|(_, session)| Some(session.owner.as_str()) != owner) + .map(|(path, _)| path.clone()) + .collect::>(); + for path in paths { + self.end(connection, &path).await; + } + } + + async fn end(&self, connection: &Connection, path: &str) { + if let Some(session) = self.sessions.lock().await.remove(path) { + let state = session.state.lock().await; + session.cancel.stop(); + if let Some(bridge) = &state.bridge { + bridge.close(); + } + if let Ok(emitter) = SignalEmitter::new(connection, path) { + let _ = SessionObject::closed(&emitter).await; + } + let _ = connection + .object_server() + .remove::(path) + .await; + } + } + + async fn configure( + &self, + connection: &Connection, + path: &OwnedObjectPath, + session: Arc, + parent: &str, + fields: Vec, + cancel: Arc, + ) -> Result, String> { + let (bridge, first, previous) = { + let state = session.state.lock().await; + let first = state.bridge.is_none(); + let bridge = match &state.bridge { + Some(bridge) => bridge.clone(), + None => Bridge::connect()?, + }; + (bridge, first, state.fields.clone()) + }; + let mut fields = fields; + let mut message = "Choose shortcuts for this application. Leave a shortcut empty to disable it. System shortcuts cannot be overridden.".to_owned(); + loop { + let selected = tokio::select! { + biased; + _ = session.cancel.wait() => return Ok(None), + _ = cancel.wait() => return Ok(None), + selected = dialog(&session.app, parent, &message, fields.clone()) => selected?, + }; + let Some(selected) = selected else { + return Ok(None); + }; + validate_selection(&fields, &selected)?; + let registration = selected + .iter() + .filter(|field| !field.trigger.trim().is_empty()) + .map(|field| json!({"id":field.id,"trigger":field.trigger.trim()})) + .collect::>(); + match bridge + .call("portal-shortcuts-register", json!(registration)) + .await + { + Ok(_) => { + let mut state = session.state.lock().await; + if session + .cancel + .stopped + .load(std::sync::atomic::Ordering::SeqCst) + || cancel.stopped.load(std::sync::atomic::Ordering::SeqCst) + { + if first + || session + .cancel + .stopped + .load(std::sync::atomic::Ordering::SeqCst) + { + bridge.close(); + } else { + let previous = previous + .iter() + .filter(|field| !field.trigger.trim().is_empty()) + .map(|field| json!({"id":field.id,"trigger":field.trigger})) + .collect::>(); + if bridge + .call("portal-shortcuts-register", json!(previous)) + .await + .is_err() + { + bridge.close(); + } + } + return Ok(None); + } + state.fields = selected.clone(); + state.bound = true; + state.bridge = Some(bridge.clone()); + state.revision = state.revision.wrapping_add(1); + drop(state); + if let Err(error) = save(&session.app, &selected) { + eprintln!("ferese shortcuts: could not save preferences: {error}"); + } + if first { + let backend = self.clone(); + let connection = connection.clone(); + let path = path.clone(); + tokio::spawn(async move { + backend.watch(connection, path, session, bridge).await; + }); + } + return Ok(Some(results(&selected))); + } + Err(error) => { + message = format!("{error}\nChoose another shortcut."); + fields = selected; + } + } + } + } + + async fn watch( + &self, + connection: Connection, + path: OwnedObjectPath, + session: Arc, + bridge: Bridge, + ) { + let emitter = SignalEmitter::new(&connection, PATH).unwrap(); + loop { + tokio::select! { + _ = session.cancel.wait() => break, + _ = tokio::time::sleep(Duration::from_millis(50)) => {}, + } + let revision = session.state.lock().await.revision; + let value = match bridge.call("portal-shortcuts-poll", json!({})).await { + Ok(value) if value["closed"] != true => value, + _ => break, + }; + let mut state = session.state.lock().await; + if session + .cancel + .stopped + .load(std::sync::atomic::Ordering::SeqCst) + { + break; + } + if let Some(active) = value["shortcuts"] + .as_array() + .filter(|_| !state.busy && state.revision == revision) + { + let mut changed = false; + for field in &mut state.fields { + if !field.trigger.is_empty() + && !active.iter().any(|item| item["id"] == field.id) + { + field.trigger.clear(); + changed = true; + } + } + if changed { + let _ = Self::shortcuts_changed(&emitter, path.clone(), results(&state.fields)) + .await; + } + } + for event in value["events"].as_array().into_iter().flatten() { + let Some(id) = event["id"].as_str() else { + continue; + }; + let timestamp = event["timestamp"].as_u64().unwrap_or(0); + if event["active"] == true { + let _ = Self::activated(&emitter, path.clone(), id, timestamp, Options::new()) + .await; + } else { + let _ = + Self::deactivated(&emitter, path.clone(), id, timestamp, Options::new()) + .await; + } + } + } + self.end(&connection, path.as_str()).await; + } +} + +#[zbus::interface(name = "org.freedesktop.impl.portal.GlobalShortcuts")] +impl GlobalShortcuts { + #[zbus(property, name = "version")] + fn version(&self) -> u32 { + 2 + } + + async fn create_session( + &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 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(zbus::fdo::Error::InvalidArgs( + "Invalid shortcut session".into(), + )); + } + let cancel = self.requests.begin(connection, &header, &handle).await?; + let mut sessions = self.sessions.lock().await; + let result = if sessions.len() >= 8 + || sessions.contains_key(session_handle.as_str()) + || cancel.stopped.load(std::sync::atomic::Ordering::SeqCst) + { + Ok((2, Options::new())) + } else { + match connection + .object_server() + .at( + &session_handle, + SessionObject { + backend: self.clone(), + path: session_handle.to_string(), + owner: owner.clone(), + }, + ) + .await + { + Err(error) => Err(zbus::fdo::Error::Failed(error.to_string())), + Ok(false) => Ok((2, Options::new())), + Ok(true) if cancel.stopped.load(std::sync::atomic::Ordering::SeqCst) => { + let _ = connection + .object_server() + .remove::(&session_handle) + .await; + Ok((1, Options::new())) + } + Ok(true) => { + let fields = load(&app_id).unwrap_or_default(); + sessions.insert( + session_handle.to_string(), + Arc::new(Session { + owner, + app: app_id, + cancel: Arc::default(), + state: Mutex::new(SessionState { + fields, + bound: false, + busy: false, + bridge: None, + revision: 0, + }), + }), + ); + Ok((0, Options::new())) + } + } + }; + drop(sessions); + self.requests.end(connection, &handle).await; + result + } + + async fn bind_shortcuts( + &self, + handle: OwnedObjectPath, + session_handle: OwnedObjectPath, + shortcuts: Shortcuts, + 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).await?; + let mut fields = fields(shortcuts).map_err(zbus::fdo::Error::InvalidArgs)?; + { + let mut state = session.state.lock().await; + if state.bound || state.busy { + return Err(zbus::fdo::Error::InvalidArgs( + "Shortcuts already bound or being configured".into(), + )); + } + for field in &mut fields { + if let Some(saved) = state.fields.iter().find(|saved| saved.id == field.id) { + field.trigger = saved.trigger.clone(); + } + } + state.busy = true; + state.revision = state.revision.wrapping_add(1); + } + let cancel = match self.requests.begin(connection, &header, &handle).await { + Ok(cancel) => cancel, + Err(error) => { + session.state.lock().await.busy = false; + return Err(error); + } + }; + let response = self + .configure( + connection, + &session_handle, + session.clone(), + &parent_window, + fields, + cancel, + ) + .await; + session.state.lock().await.busy = false; + self.requests.end(connection, &handle).await; + Ok(reply(response)) + } + + async fn list_shortcuts( + &self, + handle: OwnedObjectPath, + session_handle: OwnedObjectPath, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<(u32, Options)> { + let session = self.session(connection, &header, &session_handle).await?; + self.requests.begin(connection, &header, &handle).await?; + let result = results(&session.state.lock().await.fields); + self.requests.end(connection, &handle).await; + Ok(( + 0, + HashMap::from([("shortcuts".into(), Value::from(result).try_into().unwrap())]), + )) + } + + async fn configure_shortcuts( + &self, + session_handle: OwnedObjectPath, + parent_window: String, + _options: Options, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + let session = self.session(connection, &header, &session_handle).await?; + let fields = { + let mut state = session.state.lock().await; + if state.busy || !state.bound { + return Err(zbus::fdo::Error::InvalidArgs( + "Shortcuts are not ready to configure".into(), + )); + } + state.busy = true; + state.revision = state.revision.wrapping_add(1); + state.fields.clone() + }; + let backend = self.clone(); + let connection = connection.clone(); + tokio::spawn(async move { + let result = backend + .configure( + &connection, + &session_handle, + session.clone(), + &parent_window, + fields, + Arc::default(), + ) + .await; + session.state.lock().await.busy = false; + if let Ok(Some(shortcuts)) = result { + let emitter = SignalEmitter::new(&connection, PATH).unwrap(); + let _ = Self::shortcuts_changed(&emitter, session_handle, shortcuts).await; + } + }); + Ok(()) + } + + #[zbus(signal)] + async fn activated( + emitter: &SignalEmitter<'_>, + session_handle: OwnedObjectPath, + shortcut_id: &str, + timestamp: u64, + options: Options, + ) -> zbus::Result<()>; + + #[zbus(signal)] + async fn deactivated( + emitter: &SignalEmitter<'_>, + session_handle: OwnedObjectPath, + shortcut_id: &str, + timestamp: u64, + options: Options, + ) -> zbus::Result<()>; + + #[zbus(signal)] + async fn shortcuts_changed( + emitter: &SignalEmitter<'_>, + session_handle: OwnedObjectPath, + shortcuts: Shortcuts, + ) -> zbus::Result<()>; +} + +struct SessionObject { + backend: GlobalShortcuts, + 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(|sender| sender.as_str()) != Some(self.owner.as_str()) { + return Err(zbus::fdo::Error::AccessDenied( + "Not your shortcut session".into(), + )); + } + self.backend.end(connection, &self.path).await; + Ok(()) + } + + #[zbus(signal)] + async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>; +} + +fn fields(shortcuts: Shortcuts) -> Result, String> { + if shortcuts.is_empty() || shortcuts.len() > 64 { + return Err("Expected 1–64 shortcuts".into()); + } + let mut fields: Vec = Vec::new(); + for (id, properties) in shortcuts { + let description = properties + .get("description") + .and_then(|value| <&str>::try_from(value).ok()) + .ok_or("Missing shortcut description")?; + let trigger = properties + .get("preferred_trigger") + .map(|value| <&str>::try_from(value).map(str::to_owned)) + .transpose() + .map_err(|_| "Invalid shortcut trigger")? + .unwrap_or_default(); + if id.is_empty() + || id.len() > 256 + || description.is_empty() + || description.len() > 1024 + || trigger.len() > 256 + || id.chars().chain(description.chars()).any(char::is_control) + || fields.iter().any(|field| field.id == id) + { + return Err("Invalid or duplicate shortcut".into()); + } + fields.push(ShortcutField { + id, + description: description.into(), + trigger, + }); + } + Ok(fields) +} + +fn validate_selection( + requested: &[ShortcutField], + selected: &[ShortcutField], +) -> Result<(), String> { + if selected.len() != requested.len() + || selected.iter().zip(requested).any(|(chosen, original)| { + chosen.id != original.id + || chosen.description != original.description + || chosen.trigger.len() > 256 + || chosen.trigger.chars().any(char::is_control) + }) + { + return Err("Invalid shortcut selection".into()); + } + Ok(()) +} + +fn results(fields: &[ShortcutField]) -> Shortcuts { + fields + .iter() + .filter(|field| !field.trigger.trim().is_empty()) + .map(|field| { + ( + field.id.clone(), + HashMap::from([ + ( + "description".into(), + OwnedValue::from(zbus::zvariant::Str::from(field.description.as_str())), + ), + ( + "trigger_description".into(), + OwnedValue::from(zbus::zvariant::Str::from(field.trigger.as_str())), + ), + ]), + ) + }) + .collect() +} + +fn reply(response: Result, String>) -> (u32, Options) { + match response { + Ok(Some(shortcuts)) => ( + 0, + HashMap::from([( + "shortcuts".into(), + Value::from(shortcuts).try_into().unwrap(), + )]), + ), + Ok(None) => (1, Options::new()), + Err(error) => { + eprintln!("ferese shortcuts: {error}"); + (2, Options::new()) + } + } +} + +async fn dialog( + app: &str, + parent: &str, + description: &str, + fields: Vec, +) -> Result>, String> { + let prompt = Prompt { + title: "Application shortcuts".into(), + description: format!("{app}\n{description}"), + accept: "Allow shortcuts".into(), + parent: parent.into(), + image: None, + shortcuts: fields, + }; + let mut child = crate::backend::child_command()? + .arg("--consent") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|error| error.to_string())?; + let mut input = child.stdin.take().unwrap(); + input + .write_all(&serde_json::to_vec(&prompt).map_err(|error| error.to_string())?) + .await + .map_err(|error| error.to_string())?; + drop(input); + let output = tokio::time::timeout(Duration::from_secs(300), child.wait_with_output()) + .await + .map_err(|_| "Shortcut configuration timed out")? + .map_err(|error| error.to_string())?; + if !output.status.success() || output.stdout.is_empty() { + return Ok(None); + } + serde_json::from_slice(&output.stdout) + .map(Some) + .map_err(|error| error.to_string()) +} + +fn cache_path(app: &str) -> Option { + if app.is_empty() + || app.len() > 200 + || !app + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) + || app.starts_with('.') + { + return None; + } + Some( + dirs::data_local_dir()? + .join("ferese/portal/shortcuts") + .join(format!("{app}.json")), + ) +} + +fn load(app: &str) -> Option> { + use std::os::unix::fs::OpenOptionsExt; + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW) + .open(cache_path(app)?) + .ok()?; + if !file.metadata().ok()?.is_file() { + return None; + } + let mut bytes = Vec::new(); + file.take(128 * 1024 + 1).read_to_end(&mut bytes).ok()?; + if bytes.len() > 128 * 1024 { + return None; + } + let saved: Vec = serde_json::from_slice(&bytes).ok()?; + let shortcuts = saved + .into_iter() + .map(|field| { + ( + field.id, + HashMap::from([ + ( + "description".into(), + OwnedValue::from(zbus::zvariant::Str::from(field.description)), + ), + ( + "preferred_trigger".into(), + OwnedValue::from(zbus::zvariant::Str::from(field.trigger)), + ), + ]), + ) + }) + .collect(); + fields(shortcuts).ok() +} + +fn save(app: &str, fields: &[ShortcutField]) -> Result<(), String> { + let Some(path) = cache_path(app) else { + return Ok(()); + }; + let directory = path.parent().unwrap(); + std::fs::create_dir_all(directory).map_err(|error| error.to_string())?; + let mut file = tempfile::NamedTempFile::new_in(directory).map_err(|error| error.to_string())?; + std::io::Write::write_all( + &mut file, + &serde_json::to_vec(fields).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + file.persist(path).map_err(|error| error.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn choices_keep_requested_ids_and_descriptions_and_allow_disabling() { + let request = vec![ShortcutField { + id: "record".into(), + description: "Record screen".into(), + trigger: "Ctrl+Alt+r".into(), + }]; + let mut selected = request.clone(); + selected[0].trigger.clear(); + assert!(validate_selection(&request, &selected).is_ok()); + assert!(results(&selected).is_empty()); + selected[0].id = "other".into(); + assert!(validate_selection(&request, &selected).is_err()); + let mut selected = request.clone(); + selected[0].trigger = "a\nb".into(); + assert!(validate_selection(&request, &selected).is_err()); + } + + #[test] + fn duplicate_shortcuts_and_unsafe_cache_names_are_rejected() { + let shortcut = ( + "record".into(), + HashMap::from([( + "description".into(), + OwnedValue::from(zbus::zvariant::Str::from("Record screen")), + )]), + ); + assert!(fields(vec![shortcut.clone(), shortcut]).is_err()); + for app in ["../other", "/etc/file", ".hidden", "bad/name", ""] { + assert!(cache_path(app).is_none()); + } + } +} diff --git a/scripts/tests/test_portal_contracts.py b/scripts/tests/test_portal_contracts.py index f1428b6..a0bc998 100644 --- a/scripts/tests/test_portal_contracts.py +++ b/scripts/tests/test_portal_contracts.py @@ -54,7 +54,7 @@ def private_bus_checks(): raise AssertionError((root / "backend.log").read_text()) time.sleep(0.05) node = ET.fromstring(xml) - for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown"): + for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown", "GlobalShortcuts"): 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") @@ -90,6 +90,14 @@ def private_bus_checks(): raise AssertionError("Untrusted backend caller succeeded") except GLib.Error: pass + shortcut_session = PATH + "/session/test/shortcuts" + shortcut_handle = PATH + "/request/test/shortcuts" + create_shortcuts = GLib.Variant("(oosa{sv})", (shortcut_handle, shortcut_session, "org.test.Shortcuts", {})) + try: + call("org.freedesktop.impl.portal.GlobalShortcuts", "CreateSession", create_shortcuts) + raise AssertionError("Untrusted shortcuts creator succeeded") + except GLib.Error as error: + assert "AccessDenied" in str(error), error original_config = config.read_text() try: call("org.freedesktop.DBus.Properties", "Set", GLib.Variant("(ssv)", ("org.freedesktop.impl.portal.Lockdown", "disable-printing", GLib.Variant("b", True)))) @@ -129,6 +137,24 @@ def private_bus_checks(): config.write_text(valid_policy) bus.signal_unsubscribe(subscription) + response, values = call("org.freedesktop.impl.portal.GlobalShortcuts", "CreateSession", create_shortcuts, "(ua{sv})") + assert response == 0 and values == {} + response, values = call("org.freedesktop.impl.portal.GlobalShortcuts", "ListShortcuts", GLib.Variant("(oo)", (shortcut_handle, shortcut_session)), "(ua{sv})") + assert response == 0 and values == {"shortcuts": []}, values + duplicate = [("record", {"description": GLib.Variant("s", "Record screen")})] * 2 + try: + call("org.freedesktop.impl.portal.GlobalShortcuts", "BindShortcuts", GLib.Variant("(ooa(sa{sv})sa{sv})", (shortcut_handle, shortcut_session, duplicate, "", {}))) + raise AssertionError("Duplicate shortcut IDs accepted") + except GLib.Error as error: + assert "InvalidArgs" in str(error), error + bus.call_sync(NAME, shortcut_session, "org.freedesktop.impl.portal.Session", "Close", None, None, + Gio.DBusCallFlags.NO_AUTO_START, 5000, None) + try: + call("org.freedesktop.impl.portal.GlobalShortcuts", "ListShortcuts", GLib.Variant("(oo)", (shortcut_handle, shortcut_session))) + raise AssertionError("Closed shortcut session remains accessible") + except GLib.Error as error: + assert "AccessDenied" in str(error), error + usb_params = GLib.Variant("(oss a(sa{sv}a{sv}) a{sv})".replace(" ", ""), (PATH + "/request/test/usb", "", "org.test.App", [], {})) response, values = call("org.freedesktop.impl.portal.Usb", "AcquireDevices", usb_params, "(ua{sv})") diff --git a/scripts/tests/test_shortcuts_isolated.py b/scripts/tests/test_shortcuts_isolated.py new file mode 100644 index 0000000..3b409fe --- /dev/null +++ b/scripts/tests/test_shortcuts_isolated.py @@ -0,0 +1,95 @@ +"""Exercise shortcut connection lifetimes in an isolated nested compositor.""" +import json +import os +from pathlib import Path +import signal +import socket +import struct +import subprocess +import tempfile +import time +import unittest + +REPO = Path(__file__).resolve().parents[2] + + +@unittest.skipUnless(os.environ.get("FERESE_TEST_SHORTCUTS") == "1", "requires a Wayland host and a built compositor") +class ShortcutConnections(unittest.TestCase): + def test_disconnect_conflicts_and_configuration_reload(self): + host_runtime = Path(os.environ["XDG_RUNTIME_DIR"]) + host_display = Path(os.environ["WAYLAND_DISPLAY"]) + if not host_display.is_absolute(): + host_display = host_runtime / host_display + with tempfile.TemporaryDirectory(prefix="ferese-shortcut-test-") as directory: + root = Path(directory) + runtime = root / "runtime" + runtime.mkdir(mode=0o700) + config = root / "config/ferese/config.kdl" + config.parent.mkdir(parents=True) + config.write_text("") + log = (root / "compositor.log").open("w") + env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), WAYLAND_DISPLAY=str(host_display)) + env.pop("WAYLAND_SOCKET", None) + env.pop("FERESE_SHELL_CONTROL_SOCKET", None) + process = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env, + stdout=log, stderr=log, start_new_session=True) + connections = [] + try: + deadline = time.monotonic() + 15 + while not (runtime / "ferese/control.sock").exists(): + if process.poll() is not None or time.monotonic() >= deadline: + self.fail((root / "compositor.log").read_text()) + time.sleep(0.02) + + def connect(): + connection = socket.socket(socket.AF_UNIX) + connection.settimeout(2) + connection.connect(str(runtime / "ferese/control.sock")) + connections.append(connection) + return connection + + def call(connection, command, args=None): + data = json.dumps({"version": 1, "id": 1, "type": "command", "command": command, "args": args}).encode() + connection.sendall(struct.pack(">I", len(data)) + data) + + def receive(length): + data = b"" + while len(data) < length: + chunk = connection.recv(length - len(data)) + if not chunk: + raise AssertionError("IPC connection closed") + data += chunk + return data + + length, = struct.unpack(">I", receive(4)) + self.assertLessEqual(length, 1024 * 1024) + return json.loads(receive(length)) + + first = connect() + second = connect() + shortcut = [{"id": "record", "trigger": "Ctrl+Alt+a"}] + self.assertNotIn("error", call(first, "portal-shortcuts-register", shortcut)) + conflict = call(second, "portal-shortcuts-register", shortcut) + self.assertEqual(conflict["error"]["code"], "shortcut_conflict") + self.assertTrue(call(second, "portal-shortcuts-poll")["result"]["closed"]) + first.close() + deadline = time.monotonic() + 3 + while "error" in call(second, "portal-shortcuts-register", shortcut): + self.assertLess(time.monotonic(), deadline, "disconnected owner kept its binding") + time.sleep(0.02) + self.assertEqual(call(second, "portal-shortcuts-poll")["result"]["shortcuts"], shortcut) + config.write_text('binding keys="Ctrl+Alt+a" action="none"\n') + deadline = time.monotonic() + 5 + while call(second, "portal-shortcuts-poll")["result"]["shortcuts"]: + self.assertLess(time.monotonic(), deadline, "configuration did not revoke conflicting shortcut") + time.sleep(0.05) + finally: + for connection in connections: + connection.close() + os.killpg(process.pid, signal.SIGTERM) + process.wait(timeout=5) + log.close() + + +if __name__ == "__main__": + unittest.main()