Add configurable session-scoped global shortcut portal

This commit is contained in:
Samuel Onoja committed 2026-09-29 23:32:41 +01:00
1 parent e71cd88ff8
commit c8ed8e6c30
16 files changed
+1496 -25

No files matched your search

+59 -1
View File
@@ -485,6 +485,64 @@ pub enum BindingAction {
} }
impl Binding { impl Binding {
pub(crate) fn portal_trigger(chord: &str) -> Result<Self, ConfigError> {
if chord.len() > 256 {
return Err(ConfigError::InvalidBinding("Shortcut is too long".into()));
}
let parts = chord.split('+').collect::<Vec<_>>();
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::<Vec<_>>()
.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( pub fn matches(
&self, &self,
keycode: Keycode, keycode: Keycode,
@@ -1197,7 +1255,7 @@ fn validate_commands(commands: &HashMap<String, Vec<String>>) -> Result<(), Conf
Ok(()) Ok(())
} }
fn physical_keymap(input: &InputSettings) -> Result<xkb::Keymap, ConfigError> { pub(crate) fn physical_keymap(input: &InputSettings) -> Result<xkb::Keymap, ConfigError> {
let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS); let context = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
let options = (!input.xkb_options.is_empty()).then(|| input.xkb_options.join(",")); let options = (!input.xkb_options.is_empty()).then(|| input.xkb_options.join(","));
+17 -4
View File
@@ -150,13 +150,15 @@ impl Ferese {
SERIAL_COUNTER.next_serial(), SERIAL_COUNTER.next_serial(),
Event::time(&event) as u32, Event::time(&event) as u32,
|data, modifiers, keysym| { |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 { if data.session_lock.active {
return FilterResult::Forward; return FilterResult::Forward;
} }
if state == KeyState::Released && data.intercepted_keys.remove(&keycode) {
return FilterResult::Intercept(());
}
let symbol = keysym.modified_sym().raw(); let symbol = keysym.modified_sym().raw();
if data.overview.is_active() if data.overview.is_active()
&& matches!(symbol, keysyms::KEY_Return | keysyms::KEY_KP_Enter) && matches!(symbol, keysyms::KEY_Return | keysyms::KEY_KP_Enter)
@@ -225,6 +227,17 @@ impl Ferese {
.then(|| binding.action.clone()) .then(|| binding.action.clone())
}); });
let Some(action) = action else { 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; return FilterResult::Forward;
}; };
+65 -13
View File
@@ -5,7 +5,7 @@ use std::os::fd::AsRawFd;
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{ use std::sync::{
Arc, Arc,
mpsc::{SyncSender, sync_channel}, mpsc::{SyncSender, sync_channel},
@@ -47,10 +47,28 @@ const DEADLINE_IDLE: Duration = Duration::from_secs(1);
#[derive(Debug)] #[derive(Debug)]
struct IpcCall { struct IpcCall {
owner: u64,
request: Request, request: Request,
response: SyncSender<Response>, response: SyncSender<Response>,
} }
#[derive(Debug)]
enum IpcEvent {
Call(IpcCall),
Closed(u64),
}
struct IpcConnection {
owner: u64,
sender: channel::SyncSender<IpcEvent>,
}
impl Drop for IpcConnection {
fn drop(&mut self) {
let _ = self.sender.send(IpcEvent::Closed(self.owner));
}
}
struct ConnectionPermit { struct ConnectionPermit {
active: Arc<AtomicUsize>, active: Arc<AtomicUsize>,
} }
@@ -94,18 +112,25 @@ pub(crate) fn init(
inode: metadata.ino(), inode: metadata.ino(),
}; };
let (sender, receiver): (channel::SyncSender<IpcCall>, channel::Channel<IpcCall>) = let (sender, receiver): (channel::SyncSender<IpcEvent>, channel::Channel<IpcEvent>) =
channel::sync_channel(REQUEST_QUEUE_CAPACITY); channel::sync_channel(REQUEST_QUEUE_CAPACITY);
event_loop event_loop
.handle() .handle()
.insert_source(receiver, |event, _, state| { .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" { if call.request.command == "screenshot" {
// Deferred: this path answers the caller itself, exactly // Deferred: this path answers the caller itself, exactly
// once, whenever the request finishes or is terminated. // once, whenever the request finishes or is terminated.
state.start_screenshot(call.request, call.response); state.start_screenshot(call.request, call.response);
} else { } else {
let response = state.handle_ipc_request(call.request); let response = state.handle_ipc_request(call.owner, call.request);
let _ = call.response.send(response); let _ = call.response.send(response);
} }
} }
@@ -187,10 +212,11 @@ pub(crate) struct ScreenshotInit {
fn accept_connections( fn accept_connections(
listener: UnixListener, listener: UnixListener,
sender: channel::SyncSender<IpcCall>, sender: channel::SyncSender<IpcEvent>,
signal: LoopSignal, signal: LoopSignal,
) { ) {
let active_connections = Arc::new(AtomicUsize::new(0)); let active_connections = Arc::new(AtomicUsize::new(0));
let connection_ids = AtomicU64::new(1);
loop { loop {
let (stream, _) = match listener.accept() { let (stream, _) = match listener.accept() {
@@ -222,12 +248,13 @@ fn accept_connections(
continue; continue;
}; };
let sender = sender.clone(); let sender = sender.clone();
let owner = connection_ids.fetch_add(1, Ordering::Relaxed);
let signal = signal.clone(); let signal = signal.clone();
if let Err(error) = thread::Builder::new() if let Err(error) = thread::Builder::new()
.name("ferese-ipc-client".to_owned()) .name("ferese-ipc-client".to_owned())
.spawn(move || { .spawn(move || {
let _permit = permit; let _permit = permit;
serve_connection(stream, sender, signal); serve_connection(stream, sender, signal, owner);
}) })
{ {
tracing::warn!(%error, "could not start IPC connection worker"); tracing::warn!(%error, "could not start IPC connection worker");
@@ -254,9 +281,14 @@ fn try_acquire_connection(active: &Arc<AtomicUsize>) -> Option<ConnectionPermit>
fn serve_connection( fn serve_connection(
mut stream: UnixStream, mut stream: UnixStream,
sender: channel::SyncSender<IpcCall>, sender: channel::SyncSender<IpcEvent>,
signal: LoopSignal, signal: LoopSignal,
owner: u64,
) { ) {
let _connection = IpcConnection {
owner,
sender: sender.clone(),
};
loop { loop {
let request: Request = match read_frame(&mut stream) { let request: Request = match read_frame(&mut stream) {
Ok(request) => request, Ok(request) => request,
@@ -277,7 +309,11 @@ fn serve_connection(
}; };
let (response, receiver) = sync_channel(1); let (response, receiver) = sync_channel(1);
let exit_requested = request.command == "exit"; 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() { if sender.try_send(call).is_err() {
tracing::warn!("disconnecting IPC client because the request queue is full"); tracing::warn!("disconnecting IPC client because the request queue is full");
return; return;
@@ -300,8 +336,8 @@ fn serve_connection(
} }
impl Ferese { impl Ferese {
fn handle_ipc_request(&mut self, request: Request) -> Response { fn handle_ipc_request(&mut self, owner: u64, request: Request) -> Response {
if self.session_lock.active { if self.session_lock.active && request.command != "portal-shortcuts-poll" {
return Response::error( return Response::error(
request.id, request.id,
"session_locked", "session_locked",
@@ -312,14 +348,30 @@ impl Ferese {
return Response::error(request.id, error.code, error.message); 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), Ok(result) => Response::success(request.id, result),
Err(error) => Response::error(request.id, error.code, error.message), Err(error) => Response::error(request.id, error.code, error.message),
} }
} }
fn dispatch_ipc_command(&mut self, command: &str, args: &Value) -> Result<Value, CommandError> { fn dispatch_ipc_command(
&mut self,
owner: u64,
command: &str,
args: &Value,
) -> Result<Value, CommandError> {
match command { 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. "exit" => {} // The IPC worker stops the loop after writing the response.
"request-logout" => self.request_logout_confirmation(), "request-logout" => self.request_logout_confirmation(),
"reload-config" => self "reload-config" => self
@@ -961,7 +1013,7 @@ mod tests {
let (sender, _receiver) = channel::sync_channel(1); let (sender, _receiver) = channel::sync_channel(1);
let event_loop = EventLoop::<()>::try_new().unwrap(); let event_loop = EventLoop::<()>::try_new().unwrap();
let signal = event_loop.get_signal(); 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"; let invalid = b"not-json";
client client
+1
View File
@@ -13,6 +13,7 @@ mod input;
mod ipc; mod ipc;
mod metrics; mod metrics;
mod overview; mod overview;
mod portal_shortcuts;
mod presentation; mod presentation;
mod private_client; mod private_client;
mod reload; mod reload;
+360
View File
@@ -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<Keycode, String>,
events: VecDeque<Value>,
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<u64, Registration>);
impl PortalShortcuts {
pub(crate) fn register(
&mut self,
owner: u64,
shortcuts: Vec<Shortcut>,
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::<Vec<_>>();
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::<Vec<_>>(),
"events": registration.events.drain(..).collect::<Vec<_>>()
})
}
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));
}
}
+4
View File
@@ -257,6 +257,7 @@ pub struct Ferese {
pub(crate) overview_scrims: HashMap<OutputId, crate::winit::OverviewScrim>, pub(crate) overview_scrims: HashMap<OutputId, crate::winit::OverviewScrim>,
pub(crate) material_programs: HashMap<ErasedContextId, crate::winit::MaterialProgram>, pub(crate) material_programs: HashMap<ErasedContextId, crate::winit::MaterialProgram>,
pub(crate) material_buffers: HashMap<WlSurface, crate::winit::MaterialBuffers>, pub(crate) material_buffers: HashMap<WlSurface, crate::winit::MaterialBuffers>,
pub(crate) portal_shortcuts: crate::portal_shortcuts::PortalShortcuts,
pub(crate) pending_logout: Option<u32>, pub(crate) pending_logout: Option<u32>,
pub(crate) logout_owner: Option<smithay::reexports::wayland_server::backend::ObjectId>, pub(crate) logout_owner: Option<smithay::reexports::wayland_server::backend::ObjectId>,
pub(crate) blur_programs: HashMap<ErasedContextId, crate::winit::BlurProgram>, pub(crate) blur_programs: HashMap<ErasedContextId, crate::winit::BlurProgram>,
@@ -480,6 +481,7 @@ impl Ferese {
overview_scrims: HashMap::new(), overview_scrims: HashMap::new(),
material_programs: HashMap::new(), material_programs: HashMap::new(),
material_buffers: HashMap::new(), material_buffers: HashMap::new(),
portal_shortcuts: crate::portal_shortcuts::PortalShortcuts::default(),
pending_logout: None, pending_logout: None,
logout_owner: None, logout_owner: None,
blur_programs: HashMap::new(), blur_programs: HashMap::new(),
@@ -626,6 +628,8 @@ impl Ferese {
self.gap_config = config.gap_config; self.gap_config = config.gap_config;
self.input_settings = config.input_settings; self.input_settings = config.input_settings;
self.bindings = config.bindings; 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); let old_rules = std::mem::replace(&mut self.window_rules, config.window_rules);
self.theme_settings = config.theme_settings; self.theme_settings = config.theme_settings;
self.column_width_presets = config.column_width_presets; self.column_width_presets = config.column_width_presets;
+14 -1
View File
@@ -31,6 +31,12 @@ standard file, print and application dialogs. Install both backends.
policy. Only the portal frontend may write backend policy properties. policy. Only the portal frontend may write backend policy properties.
- **USB:** native confirmation showing device vendor/model and requested read-only - **USB:** native confirmation showing device vendor/model and requested read-only
or read/write access. The portal frontend handles device enumeration and opening. 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. - **ScreenCast:** monitor sharing with a native picker and PipeWire streams.
See [screen sharing](screen-sharing.md) for lifetime and recording details. 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 | | Access, Account, AppChooser, DynamicLauncher, FileChooser, Notification, Print | Delegated to GTK |
| Background | Native application state, per-instance consent and XDG autostart | | Background | Native application state, per-instance consent and XDG autostart |
| Usb | Native consent; standard frontend handles enumeration and device descriptors | | 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 | | RemoteDesktop | Missing authorized input injection and EIS transport |
| Clipboard | Missing remote-session clipboard transfer | | Clipboard | Missing remote-session clipboard transfer |
| InputCapture | Missing zones, pointer barriers and EIS transport | | 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 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 The contract test compares introspection against the installed official backend
XML, verifies appearance changes and invalid-save retention, and checks that XML, verifies appearance changes and invalid-save retention, and checks that
sensitive methods reject callers outside the portal frontend. It does not open sensitive methods reject callers outside the portal frontend. It does not open
+1
View File
@@ -7,3 +7,4 @@ org.freedesktop.impl.portal.Wallpaper=ferese;
org.freedesktop.impl.portal.Background=ferese; org.freedesktop.impl.portal.Background=ferese;
org.freedesktop.impl.portal.Usb=ferese; org.freedesktop.impl.portal.Usb=ferese;
org.freedesktop.impl.portal.Lockdown=ferese; org.freedesktop.impl.portal.Lockdown=ferese;
org.freedesktop.impl.portal.GlobalShortcuts=ferese;
+1 -1
View File
@@ -1,4 +1,4 @@
[portal] [portal]
DBusName=org.freedesktop.impl.portal.desktop.ferese DBusName=org.freedesktop.impl.portal.desktop.ferese
Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Usb;org.freedesktop.impl.portal.Lockdown; 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; UseIn=Ferese;
+3
View File
@@ -668,6 +668,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let requests = crate::desktop::Requests::default(); let requests = crate::desktop::Requests::default();
let background = crate::permissions::Background::new(requests.clone()); let background = crate::permissions::Background::new(requests.clone());
let lockdown = crate::lockdown::Lockdown::new(); let lockdown = crate::lockdown::Lockdown::new();
let shortcuts = crate::shortcuts::GlobalShortcuts::new(requests.clone());
let connection = zbus::connection::Builder::session()? let connection = zbus::connection::Builder::session()?
.name("org.freedesktop.impl.portal.desktop.ferese")? .name("org.freedesktop.impl.portal.desktop.ferese")?
.serve_at(PATH, backend.clone())? .serve_at(PATH, backend.clone())?
@@ -677,6 +678,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
.serve_at(PATH, crate::permissions::Usb(requests.clone()))? .serve_at(PATH, crate::permissions::Usb(requests.clone()))?
.serve_at(PATH, background.clone())? .serve_at(PATH, background.clone())?
.serve_at(PATH, lockdown.clone())? .serve_at(PATH, lockdown.clone())?
.serve_at(PATH, shortcuts.clone())?
.serve_at( .serve_at(
"/org/ferese/ScreenRecorder", "/org/ferese/ScreenRecorder",
RecorderControl(backend.clone()), RecorderControl(backend.clone()),
@@ -698,6 +700,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
Err(_) => None, Err(_) => None,
}; };
requests.revoke_stale(owner.as_deref()).await; requests.revoke_stale(owner.as_deref()).await;
shortcuts.revoke_stale(&connection, owner.as_deref()).await;
let stale: Vec<_> = backend let stale: Vec<_> = backend
.sessions .sessions
.lock() .lock()
+44 -4
View File
@@ -2,7 +2,7 @@ use cosmic::{
Element, Element,
app::{Core, Settings, Task}, app::{Core, Settings, Task},
iced::{Alignment, Length}, iced::{Alignment, Length},
widget::{button, column, container, image, row, scrollable}, widget::{button, column, container, image, row, scrollable, text_input},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::io::{Read, Write}; use std::io::{Read, Write};
@@ -14,6 +14,15 @@ pub(crate) struct Prompt {
pub accept: String, pub accept: String,
pub parent: String, pub parent: String,
pub image: Option<std::path::PathBuf>, pub image: Option<std::path::PathBuf>,
#[serde(default)]
pub shortcuts: Vec<ShortcutField>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct ShortcutField {
pub id: String,
pub description: String,
pub trigger: String,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -30,6 +39,7 @@ enum Message {
), ),
Accept, Accept,
Cancel, Cancel,
Shortcut(usize, String),
} }
struct Consent { struct Consent {
@@ -45,11 +55,18 @@ struct Consent {
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> { pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut input = String::new(); let mut input = String::new();
std::io::stdin() std::io::stdin()
.take(64 * 1024) .take(256 * 1024 + 1)
.read_to_string(&mut input)?; .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 prompt: Prompt = serde_json::from_str(&input)?;
let (theme, font, background, _) = crate::picker::appearance(); 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::<Consent>( cosmic::app::run::<Consent>(
Settings::default() Settings::default()
.size(cosmic::iced::Size::new(440., height)) .size(cosmic::iced::Size::new(440., height))
@@ -73,6 +90,7 @@ impl cosmic::Application for Consent {
fn core(&self) -> &Core { fn core(&self) -> &Core {
&self.core &self.core
} }
fn core_mut(&mut self) -> &mut Core { fn core_mut(&mut self) -> &mut Core {
&mut self.core &mut self.core
} }
@@ -134,11 +152,20 @@ impl cosmic::Application for Consent {
return cosmic::iced::exit(); return cosmic::iced::exit();
} }
Message::Accept => { Message::Accept => {
if self.prompt.shortcuts.is_empty() {
println!("true"); println!("true");
} else {
println!("{}", serde_json::to_string(&self.prompt.shortcuts).unwrap());
}
let _ = std::io::stdout().flush(); let _ = std::io::stdout().flush();
return cosmic::iced::exit(); return cosmic::iced::exit();
} }
Message::Cancel => 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() Task::none()
} }
@@ -153,9 +180,22 @@ impl cosmic::Application for Consent {
fn view(&self) -> Element<'_, Message> { fn view(&self) -> Element<'_, Message> {
let text = |value: String| ferese_theme::text(value, self.font); 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![ let mut content = column![
text(self.prompt.title.clone()).size(22), text(self.prompt.title.clone()).size(22),
scrollable(text(self.prompt.description.clone())).height(Length::Fill) scrollable(details).height(Length::Fill)
] ]
.spacing(16); .spacing(16);
if let Some(path) = &self.prompt.image { if let Some(path) = &self.prompt.image {
+1
View File
@@ -192,6 +192,7 @@ pub(crate) async fn consent(
}, },
accept: accept.into(), accept: accept.into(),
image, image,
shortcuts: Vec::new(),
}; };
let mut child = crate::backend::child_command()? let mut child = crate::backend::child_command()?
.arg("--consent") .arg("--consent")
+1
View File
@@ -7,6 +7,7 @@ mod parent;
mod permissions; mod permissions;
mod picker; mod picker;
mod settings; mod settings;
mod shortcuts;
mod stream; mod stream;
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
+803
View File
@@ -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<BridgeInner>);
struct BridgeInner {
stream: std::sync::Mutex<UnixStream>,
shutdown: UnixStream,
}
impl Bridge {
fn connect() -> Result<Self, String> {
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<Json, String> {
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<Cancel>,
state: Mutex<SessionState>,
}
struct SessionState {
fields: Vec<ShortcutField>,
bound: bool,
busy: bool,
bridge: Option<Bridge>,
revision: u64,
}
#[derive(Clone, Default)]
pub(crate) struct GlobalShortcuts {
sessions: Arc<Mutex<HashMap<String, Arc<Session>>>>,
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<Arc<Session>> {
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::<Vec<_>>();
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::<SessionObject, _>(path)
.await;
}
}
async fn configure(
&self,
connection: &Connection,
path: &OwnedObjectPath,
session: Arc<Session>,
parent: &str,
fields: Vec<ShortcutField>,
cancel: Arc<Cancel>,
) -> Result<Option<Shortcuts>, 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::<Vec<_>>();
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::<Vec<_>>();
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<Session>,
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::<SessionObject, _>(&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<Vec<ShortcutField>, String> {
if shortcuts.is_empty() || shortcuts.len() > 64 {
return Err("Expected 1–64 shortcuts".into());
}
let mut fields: Vec<ShortcutField> = 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<Option<Shortcuts>, 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<ShortcutField>,
) -> Result<Option<Vec<ShortcutField>>, 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<std::path::PathBuf> {
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<Vec<ShortcutField>> {
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<ShortcutField> = 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());
}
}
}
+27 -1
View File
@@ -54,7 +54,7 @@ def private_bus_checks():
raise AssertionError((root / "backend.log").read_text()) raise AssertionError((root / "backend.log").read_text())
time.sleep(0.05) time.sleep(0.05)
node = ET.fromstring(xml) node = ET.fromstring(xml)
for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown"): for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown", "GlobalShortcuts"):
interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']") interface = node.find(f"interface[@name='org.freedesktop.impl.portal.{name}']")
assert interface is not None, name assert interface is not None, name
expected = ET.parse(f"/usr/share/dbus-1/interfaces/org.freedesktop.impl.portal.{name}.xml").getroot().find("interface") expected = ET.parse(f"/usr/share/dbus-1/interfaces/org.freedesktop.impl.portal.{name}.xml").getroot().find("interface")
@@ -90,6 +90,14 @@ def private_bus_checks():
raise AssertionError("Untrusted backend caller succeeded") raise AssertionError("Untrusted backend caller succeeded")
except GLib.Error: except GLib.Error:
pass 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() original_config = config.read_text()
try: try:
call("org.freedesktop.DBus.Properties", "Set", GLib.Variant("(ssv)", ("org.freedesktop.impl.portal.Lockdown", "disable-printing", GLib.Variant("b", True)))) 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) config.write_text(valid_policy)
bus.signal_unsubscribe(subscription) 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(" ", ""), usb_params = GLib.Variant("(oss a(sa{sv}a{sv}) a{sv})".replace(" ", ""),
(PATH + "/request/test/usb", "", "org.test.App", [], {})) (PATH + "/request/test/usb", "", "org.test.App", [], {}))
response, values = call("org.freedesktop.impl.portal.Usb", "AcquireDevices", usb_params, "(ua{sv})") response, values = call("org.freedesktop.impl.portal.Usb", "AcquireDevices", usb_params, "(ua{sv})")
+95
View File
@@ -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()