diff --git a/compositor/src/handlers/compositor.rs b/compositor/src/handlers/compositor.rs index 11c8009..87f5fa4 100644 --- a/compositor/src/handlers/compositor.rs +++ b/compositor/src/handlers/compositor.rs @@ -105,8 +105,7 @@ impl CompositorHandler for Ferese { self.backdrop_generation = self.backdrop_generation.wrapping_add(1); crate::backends::direct::render_all(self); if self.idle_inhibitors.remove(surface).is_some() { - self.idle_notifier_state - .set_is_inhibited(!self.idle_inhibitors.is_empty()); + self.refresh_idle_inhibition(); } if matches!(&self.cursor_status, smithay::input::pointer::CursorImageStatus::Surface(cursor) if cursor == surface) { diff --git a/compositor/src/handlers/mod.rs b/compositor/src/handlers/mod.rs index 8ab80ce..2726362 100644 --- a/compositor/src/handlers/mod.rs +++ b/compositor/src/handlers/mod.rs @@ -126,8 +126,7 @@ impl IdleInhibitHandler for Ferese { self.idle_inhibitors.remove(&surface); } } - self.idle_notifier_state - .set_is_inhibited(!self.idle_inhibitors.is_empty()); + self.refresh_idle_inhibition(); } } diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 3f3455b..378cd58 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -122,10 +122,33 @@ pub(crate) fn init( IpcEvent::Call(call) => call, IpcEvent::Closed(owner) => { state.portal_shortcuts.remove(owner); + state.portal_session.remove(owner); + state.refresh_idle_inhibition(); return; } }; - if call.request.command == "screenshot-window" { + if call.request.command == "session-watch" { + if let Err(error) = validate_request(&call.request) { + let _ = call.response.send(Response::error( + call.request.id, + error.code, + error.message, + )); + } else if let Some(since) = call.request.args["since"].as_u64() { + state.portal_session.watch( + call.owner, + call.request.id, + since, + call.response, + ); + } else { + let _ = call.response.send(Response::error( + call.request.id, + "invalid_argument", + "Missing session revision", + )); + } + } else if call.request.command == "screenshot-window" { state.start_window_screenshot(call.request, call.response); } else if call.request.command == "screenshot" { // Deferred: this path answers the caller itself, exactly @@ -322,8 +345,34 @@ fn serve_connection( tracing::warn!("disconnecting IPC client because the request queue is full"); return; } - let Ok(response) = receiver.recv() else { - return; + let response = loop { + match receiver.recv_timeout(Duration::from_secs(1)) { + Ok(response) => break response, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + let mut byte = 0_u8; + // SAFETY: the live stream owns this fd and byte is writable. + let result = unsafe { + libc::recv( + std::os::fd::AsRawFd::as_raw_fd(&stream), + (&mut byte as *mut u8).cast(), + 1, + libc::MSG_PEEK | libc::MSG_DONTWAIT, + ) + }; + if result == 0 { + return; + } + if result < 0 + && !matches!( + io::Error::last_os_error().kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted + ) + { + return; + } + } + } }; let exit_accepted = exit_requested && response.error.is_none(); if let Err(error) = write_frame(&mut stream, &response) { @@ -342,6 +391,13 @@ fn serve_connection( } } +fn u32_arg(args: &Value, key: &str) -> Result { + args[key] + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| CommandError::new("invalid_argument", format!("Missing or invalid {key}"))) +} + fn discard_undelivered_screenshot(response: &Response) { if response.error.is_none() && let Some(path) = response @@ -357,7 +413,18 @@ fn discard_undelivered_screenshot(response: &Response) { impl Ferese { fn handle_ipc_request(&mut self, owner: u64, request: Request) -> Response { - if self.session_lock.active && request.command != "portal-shortcuts-poll" { + if self.session_lock.active + && !matches!( + request.command.as_str(), + "portal-shortcuts-poll" + | "get-session-state" + | "portal-inhibit" + | "portal-monitor-register" + | "portal-monitor-ack" + | "logind-session-ending" + | "cancel-session-end" + ) + { return Response::error( request.id, "session_locked", @@ -381,6 +448,72 @@ impl Ferese { args: &Value, ) -> Result { match command { + "portal-inhibit" => { + let inhibition = serde_json::from_value(args.clone()) + .map_err(|error| CommandError::new("invalid_argument", error.to_string()))?; + self.portal_session + .register(owner, inhibition) + .map_err(|error| CommandError::new("invalid_argument", error))?; + self.refresh_idle_inhibition(); + return Ok(json!({})); + } + "get-session-state" => { + return Ok(self.portal_session.snapshot()); + } + "portal-monitor-register" => { + self.portal_session + .register_monitor(owner) + .map_err(|error| CommandError::new("invalid_argument", error))?; + let mut result = self.portal_session.snapshot(); + result["monitor-owner"] = json!(owner); + return Ok(result); + } + "portal-monitor-ack" => { + let token = u32_arg(args, "token")?; + self.portal_session + .acknowledge(owner, token) + .map_err(|error| CommandError::new("invalid_argument", error))?; + return Ok(json!({})); + } + "begin-session-end" => { + self.portal_session.begin_owned_query(owner); + return Ok(self.portal_session.snapshot()); + } + "cancel-session-end" => { + self.portal_session.cancel_query(u32_arg(args, "token")?); + return Ok(json!({})); + } + "validate-session-end" => { + self.portal_session + .validate_end( + u32_arg(args, "token")?, + u32_arg(args, "inhibitor-revision")?, + args["force"].as_bool().ok_or_else(|| { + CommandError::new("invalid_argument", "Missing force confirmation") + })?, + ) + .map_err(|error| CommandError::new("inhibited", error))?; + return Ok(json!({})); + } + "commit-session-end" => { + self.portal_session + .commit_end( + u32_arg(args, "token")?, + u32_arg(args, "inhibitor-revision")?, + args["force"].as_bool().ok_or_else(|| { + CommandError::new("invalid_argument", "Missing force confirmation") + })?, + ) + .map_err(|error| CommandError::new("inhibited", error))?; + return Ok(json!({})); + } + "logind-session-ending" => { + let ending = args["ending"].as_bool().ok_or_else(|| { + CommandError::new("invalid_argument", "Missing shutdown state") + })?; + self.portal_session.set_phase(if ending { 3 } else { 1 }); + return Ok(json!({})); + } "portal-shortcuts-register" => { let shortcuts = serde_json::from_value(args.clone()) .map_err(|error| CommandError::new("invalid_argument", error.to_string()))?; diff --git a/compositor/src/main.rs b/compositor/src/main.rs index cc6b5f7..38a8734 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_session; mod portal_shortcuts; mod presentation; mod private_client; diff --git a/compositor/src/portal_session.rs b/compositor/src/portal_session.rs new file mode 100644 index 0000000..2ac65f7 --- /dev/null +++ b/compositor/src/portal_session.rs @@ -0,0 +1,422 @@ +use ferese_ipc::Response; +use serde::Deserialize; +use serde_json::{Value, json}; +use smithay::reexports::calloop::timer::{TimeoutAction, Timer}; +use std::{ + collections::{HashMap, HashSet, VecDeque}, + sync::mpsc::SyncSender, + time::{Duration, Instant}, +}; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Inhibition { + flags: u32, + app: String, + reason: String, +} + +struct Query { + token: u32, + owner: Option, + deadline: Instant, + pending: HashSet, +} + +pub(crate) struct PortalSession { + inhibitors: HashMap, + monitors: HashSet, + waiters: HashMap)>, + phase: u32, + locked: bool, + revision: u64, + inhibitor_revision: u32, + next_query: u32, + query: Option, + transitions: VecDeque, +} + +impl Default for PortalSession { + fn default() -> Self { + Self { + inhibitors: HashMap::new(), + monitors: HashSet::new(), + waiters: HashMap::new(), + phase: 1, + locked: false, + revision: 0, + inhibitor_revision: 0, + next_query: 0, + query: None, + transitions: VecDeque::new(), + } + } +} + +impl PortalSession { + pub(crate) fn register(&mut self, owner: u64, inhibition: Inhibition) -> Result<(), String> { + if inhibition.flags == 0 + || inhibition.flags & !15 != 0 + || inhibition.app.len() > 512 + || inhibition.reason.len() > 1024 + || inhibition + .app + .chars() + .chain(inhibition.reason.chars()) + .any(char::is_control) + { + return Err("Invalid session inhibition".into()); + } + if self.inhibitors.len() >= 16 && !self.inhibitors.contains_key(&owner) { + return Err("Session inhibition limit reached".into()); + } + self.inhibitors.insert(owner, inhibition); + self.inhibitor_revision = self.inhibitor_revision.wrapping_add(1); + Ok(()) + } + + pub(crate) fn register_monitor(&mut self, owner: u64) -> Result<(), String> { + if self.monitors.len() >= 8 && !self.monitors.contains(&owner) { + return Err("Session monitor limit reached".into()); + } + self.monitors.insert(owner); + if let Some(query) = &mut self.query + && self.phase == 2 + { + query.pending.insert(owner); + } + Ok(()) + } + + pub(crate) fn acknowledge(&mut self, owner: u64, token: u32) -> Result<(), String> { + if !self.monitors.contains(&owner) { + return Err("Unknown session monitor".into()); + } + if let Some(query) = &mut self.query + && query.token == token + && self.phase == 2 + && query.pending.remove(&owner) + { + self.changed(); + } + Ok(()) + } + + pub(crate) fn remove(&mut self, owner: u64) { + self.waiters.remove(&owner); + if self.phase == 2 + && self + .query + .as_ref() + .is_some_and(|query| query.owner == Some(owner)) + { + self.set_phase(1); + } + if self.inhibitors.remove(&owner).is_some() { + self.inhibitor_revision = self.inhibitor_revision.wrapping_add(1); + } + let monitor_removed = self.monitors.remove(&owner); + let acknowledged = self + .query + .as_mut() + .is_some_and(|query| query.pending.remove(&owner)); + if monitor_removed || acknowledged { + self.changed(); + } + } + + fn idle_inhibited(&self) -> bool { + self.inhibitors + .values() + .any(|inhibitor| inhibitor.flags & 8 != 0) + } + + pub(crate) fn begin_query(&mut self) -> u32 { + self.next_query = self.next_query.wrapping_add(1).max(1); + let token = self.next_query; + self.query = Some(Query { + token, + owner: None, + deadline: Instant::now() + Duration::from_secs(1), + pending: self.monitors.clone(), + }); + self.phase = 2; + self.changed(); + token + } + + pub(crate) fn begin_owned_query(&mut self, owner: u64) -> u32 { + let token = self.begin_query(); + self.query.as_mut().unwrap().owner = Some(owner); + token + } + + pub(crate) fn cancel_query(&mut self, token: u32) { + if self + .query + .as_ref() + .is_some_and(|query| query.token == token) + { + self.set_phase(1); + } + } + + pub(crate) fn query_ready(&self) -> bool { + self.phase == 2 + && self + .query + .as_ref() + .is_some_and(|query| query.pending.is_empty() || Instant::now() >= query.deadline) + } + + pub(crate) fn validate_end( + &self, + token: u32, + inhibitor_revision: u32, + force: bool, + ) -> Result<(), String> { + if !self.query_ready() + || !self + .query + .as_ref() + .is_some_and(|query| query.token == token) + { + return Err( + "Session-ending confirmation expired or applications are still responding".into(), + ); + } + if self.inhibitor_revision != inhibitor_revision + || (!force + && self + .inhibitors + .values() + .any(|inhibitor| inhibitor.flags & 1 != 0)) + { + return Err( + "Applications changed their inhibitors; review the confirmation again".into(), + ); + } + Ok(()) + } + + pub(crate) fn commit_end( + &mut self, + token: u32, + inhibitor_revision: u32, + force: bool, + ) -> Result<(), String> { + self.validate_end(token, inhibitor_revision, force)?; + self.set_phase(3); + Ok(()) + } + + pub(crate) fn set_phase(&mut self, phase: u32) { + if phase == 2 { + self.begin_query(); + return; + } + if phase == 1 { + self.query = None; + } + if self.phase != phase { + self.phase = phase; + self.changed(); + } + } + + pub(crate) fn set_locked(&mut self, locked: bool) { + if self.locked != locked { + self.locked = locked; + self.changed(); + } + } + + fn transition(&self) -> Value { + json!({"screensaver-active": self.locked, "session-state": self.phase, "revision": self.revision, "query-token": self.query.as_ref().map_or(0, |query| query.token)}) + } + + fn changed(&mut self) { + self.revision = self.revision.wrapping_add(1); + if self.transitions.len() == 64 { + self.transitions.pop_front(); + } + self.transitions.push_back(self.transition()); + let snapshot = self.snapshot(); + for (_, (id, response)) in self.waiters.drain() { + let _ = response.try_send(Response::success(id, snapshot.clone())); + } + } + + pub(crate) fn watch( + &mut self, + owner: u64, + id: u64, + since: u64, + response: SyncSender, + ) { + if since != self.revision { + let _ = response.try_send(Response::success(id, self.snapshot())); + } else { + self.waiters.insert(owner, (id, response)); + } + } + + pub(crate) fn snapshot(&self) -> Value { + let inhibitors = self.inhibitors.values().map(|inhibitor| json!({"flags": inhibitor.flags, "app": inhibitor.app, "reason": inhibitor.reason})).collect::>(); + let remaining = self.query.as_ref().map_or(0, |query| { + query + .deadline + .saturating_duration_since(Instant::now()) + .as_millis() as u64 + }); + json!({"screensaver-active": self.locked, "session-state": self.phase, "revision": self.revision, + "inhibitors": inhibitors, "inhibitor-revision": self.inhibitor_revision, + "query-token": self.query.as_ref().map_or(0, |query| query.token), "query-ready": self.query_ready(), + "query-remaining-ms": remaining, "transitions": self.transitions, "monitor-owners": self.monitors}) + } +} + +impl crate::Ferese { + pub(crate) fn refresh_idle_inhibition(&mut self) { + self.idle_notifier_state.set_is_inhibited( + !self.idle_inhibitors.is_empty() || self.portal_session.idle_inhibited(), + ); + } + + pub(crate) fn end_portal_session(&mut self) { + let token = self.portal_session.query.as_ref().map(|query| query.token); + let result = self.loop_handle.insert_source( + Timer::from_duration(Duration::from_millis(200)), + move |_, _, state| { + if state.portal_session.phase == 3 + && state.portal_session.query.as_ref().map(|query| query.token) == token + { + state.loop_signal.stop(); + } + TimeoutAction::Drop + }, + ); + if let Err(error) = result { + tracing::error!(%error, "failed to schedule session ending"); + self.loop_signal.stop(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn inhibition(flags: u32) -> Inhibition { + Inhibition { + flags, + app: "test.app".into(), + reason: "Playing media".into(), + } + } + + #[test] + fn inhibition_is_connection_scoped_and_invalid_flags_do_not_replace_it() { + let mut session = PortalSession::default(); + session.register(1, inhibition(8)).unwrap(); + session.register(2, inhibition(8)).unwrap(); + assert!(session.register(1, inhibition(16)).is_err()); + session.remove(1); + assert!(session.idle_inhibited()); + session.remove(2); + assert!(!session.idle_inhibited()); + } + + #[test] + fn acknowledgements_are_generation_scoped_and_disconnect_releases_pending_monitor() { + let mut session = PortalSession::default(); + session.register_monitor(1).unwrap(); + session.register_monitor(2).unwrap(); + let old = session.begin_query(); + assert!(!session.query_ready()); + let token = session.begin_query(); + session.acknowledge(1, old).unwrap(); + assert!(!session.query_ready()); + session.acknowledge(1, token).unwrap(); + assert!(!session.query_ready()); + session.remove(2); + assert!(session.query_ready()); + session.cancel_query(old); + assert!(session.query_ready()); + session.cancel_query(token); + assert!(!session.query_ready()); + } + + #[test] + fn late_inhibitor_requires_a_new_confirmation_and_force_is_explicit() { + let mut session = PortalSession::default(); + let token = session.begin_query(); + let approved = session.inhibitor_revision; + session.register(1, inhibition(1)).unwrap(); + assert!(session.commit_end(token, approved, true).is_err()); + assert!( + session + .commit_end(token, session.inhibitor_revision, false) + .is_err() + ); + session + .commit_end(token, session.inhibitor_revision, true) + .unwrap(); + assert_eq!(session.snapshot()["session-state"], 3); + assert!( + session + .commit_end(token, session.inhibitor_revision, true) + .is_err() + ); + } + + #[test] + fn validation_keeps_query_open_and_owner_disconnect_cancels_it() { + let mut session = PortalSession::default(); + let token = session.begin_owned_query(9); + session.validate_end(token, 0, false).unwrap(); + assert_eq!(session.snapshot()["session-state"], 2); + session.remove(9); + assert_eq!(session.snapshot()["session-state"], 1); + assert!(session.validate_end(token, 0, false).is_err()); + } + + #[test] + fn old_logout_token_does_not_cancel_a_new_shutdown_query() { + let mut session = PortalSession::default(); + let logout = session.begin_query(); + let shutdown = session.begin_owned_query(9); + session.cancel_query(logout); + session.validate_end(shutdown, 0, false).unwrap(); + } + + #[test] + fn unresponsive_monitors_have_a_one_second_deadline() { + let mut session = PortalSession::default(); + session.register_monitor(1).unwrap(); + session.begin_query(); + assert!(!session.query_ready()); + session.query.as_mut().unwrap().deadline = Instant::now() - Duration::from_millis(1); + assert!(session.query_ready()); + } + + #[test] + fn state_watch_preserves_fast_transitions_and_stops_on_disconnect() { + let mut session = PortalSession::default(); + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + session.watch(1, 10, 0, sender); + assert!(receiver.try_recv().is_err()); + session.set_locked(true); + let response = receiver.try_recv().unwrap(); + assert_eq!(response.result.unwrap()["screensaver-active"], true); + session.set_locked(false); + assert_eq!( + session.snapshot()["transitions"].as_array().unwrap().len(), + 2 + ); + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + session.watch(1, 11, session.revision, sender); + session.remove(1); + assert!(receiver.recv().is_err()); + } +} diff --git a/compositor/src/session_lock.rs b/compositor/src/session_lock.rs index 52aa8ad..6690257 100644 --- a/compositor/src/session_lock.rs +++ b/compositor/src/session_lock.rs @@ -123,6 +123,7 @@ impl SessionLockHandler for Ferese { } self.cancel_logout_confirmation(); self.session_lock.active = true; + self.portal_session.set_locked(true); self.session_lock.owner = Some(confirmation.ext_session_lock().clone()); self.session_lock.confirmation = Some(confirmation); for capture in self.pending_screencopies.drain(..) { @@ -165,6 +166,7 @@ impl SessionLockHandler for Ferese { fn unlock(&mut self) { self.session_lock = Lock::default(); + self.portal_session.set_locked(false); self.restore_keyboard_focus(); crate::backends::direct::render_all(self); tracing::info!("session unlocked by lock owner"); diff --git a/compositor/src/shell_control.rs b/compositor/src/shell_control.rs index 6f833b0..c79242e 100644 --- a/compositor/src/shell_control.rs +++ b/compositor/src/shell_control.rs @@ -14,7 +14,7 @@ use smithay::{ use crate::{Ferese, private_client::ClientCapabilities, state::ClientState}; pub(crate) fn init_global(display: &DisplayHandle) { - display.create_global::(3, ()); + display.create_global::(4, ()); } pub(crate) fn config_chunks(source: &str) -> Vec<&str> { @@ -137,11 +137,25 @@ impl Dispatch for Ferese { } } ferese_shell_v1::Request::ConfirmLogout { serial } => { - if state.logout_owner.as_ref() == Some(&shell.id()) - && consume_logout_confirmation(&mut state.pending_logout, serial) - { - state.logout_owner = None; - state.loop_signal.stop(); + let snapshot = state.portal_session.snapshot(); + let token = snapshot["query-token"].as_u64().unwrap_or(0) as u32; + let revision = snapshot["inhibitor-revision"].as_u64().unwrap_or(0) as u32; + state.confirm_portal_logout(shell, serial, token, revision, false); + } + ferese_shell_v1::Request::ConfirmLogoutWithInhibitors { + serial, + inhibitor_revision, + query_token, + force, + } => { + if force <= 1 { + state.confirm_portal_logout( + shell, + serial, + query_token, + inhibitor_revision, + force == 1, + ); } } ferese_shell_v1::Request::CancelLogout { serial } => { @@ -183,12 +197,20 @@ impl Dispatch for Ferese { _data: &(), ) { if state.logout_owner.as_ref() == Some(&shell.id()) { - state.pending_logout = None; - state.logout_owner = None; + state.cancel_logout_confirmation(); } } } +fn logout_confirmation_matches( + pending: Option, + query: Option, + serial: u32, + token: u32, +) -> bool { + pending == Some(serial) && query == Some(token) +} + fn consume_logout_confirmation(pending: &mut Option, serial: u32) -> bool { if *pending == Some(serial) { *pending = None; @@ -199,6 +221,34 @@ fn consume_logout_confirmation(pending: &mut Option, serial: u32) -> bool { } impl Ferese { + fn confirm_portal_logout( + &mut self, + shell: &FereseShellV1, + serial: u32, + token: u32, + revision: u32, + force: bool, + ) { + if self.logout_owner.as_ref() != Some(&shell.id()) + || !logout_confirmation_matches(self.pending_logout, self.logout_query, serial, token) + { + return; + } + match self.portal_session.commit_end(token, revision, force) { + Ok(()) => { + consume_logout_confirmation(&mut self.pending_logout, serial); + self.logout_owner = None; + self.logout_query = None; + self.end_portal_session(); + } + Err(error) => { + tracing::debug!(%error, "logout requires a new confirmation"); + self.cancel_logout_confirmation(); + self.request_logout_confirmation(); + } + } + } + pub(crate) fn request_logout_confirmation(&mut self) { if self.session_lock.active { return; @@ -216,6 +266,7 @@ impl Ferese { self.cancel_logout_confirmation(); let serial = u32::from(smithay::utils::SERIAL_COUNTER.next_serial()); let shell = &shells[0]; + self.logout_query = Some(self.portal_session.begin_query()); self.pending_logout = Some(serial); self.logout_owner = Some(shell.id()); let output_name = self @@ -225,6 +276,9 @@ impl Ferese { } pub(crate) fn cancel_logout_confirmation(&mut self) { + if let Some(token) = self.logout_query.take() { + self.portal_session.cancel_query(token); + } if let Some(serial) = self.pending_logout.take() { for shell in self .shell_resources @@ -507,6 +561,18 @@ fn send_request_failed(shell: &FereseShellV1, request: ferese_shell_v1::FailedRe #[cfg(test)] mod tests { + #[test] + fn logout_serial_cannot_authorize_a_different_session_query() { + assert!(super::logout_confirmation_matches(Some(42), Some(9), 42, 9)); + assert!(!super::logout_confirmation_matches( + Some(42), + Some(9), + 42, + 10 + )); + assert!(!super::logout_confirmation_matches(None, Some(9), 42, 9)); + } + #[test] fn logout_requires_the_current_confirmation_and_can_be_cancelled() { let mut pending = Some(42); diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 0b9aa2f..7643399 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -231,6 +231,8 @@ pub struct Ferese { pub socket_name: OsString, pub display_handle: DisplayHandle, pub loop_signal: LoopSignal, + pub(crate) loop_handle: smithay::reexports::calloop::LoopHandle<'static, Self>, + pub(crate) portal_session: crate::portal_session::PortalSession, pub space: Space, pub workspaces: WorkspaceSet, pub output_workspaces: OutputWorkspaceMap, @@ -259,6 +261,7 @@ pub struct Ferese { pub(crate) material_buffers: HashMap, pub(crate) portal_shortcuts: crate::portal_shortcuts::PortalShortcuts, pub(crate) pending_logout: Option, + pub(crate) logout_query: Option, pub(crate) logout_owner: Option, pub(crate) blur_programs: HashMap, pub(crate) backdrop_generation: u64, @@ -448,6 +451,8 @@ impl Ferese { socket_name, display_handle, loop_signal: event_loop.get_signal(), + loop_handle: event_loop.handle(), + portal_session: Default::default(), space: Space::default(), workspaces: WorkspaceSet::new( config.layout_mode, @@ -483,6 +488,7 @@ impl Ferese { material_buffers: HashMap::new(), portal_shortcuts: crate::portal_shortcuts::PortalShortcuts::default(), pending_logout: None, + logout_query: None, logout_owner: None, blur_programs: HashMap::new(), backdrop_generation: 0, diff --git a/docs/portals.md b/docs/portals.md index 60a26e3..8872a79 100644 --- a/docs/portals.md +++ b/docs/portals.md @@ -41,6 +41,12 @@ standard file, print and application dialogs. Install both backends. 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. +- **Inhibit:** connection-scoped logout, suspend and idle inhibitors, backed by + logind descriptors and compositor idle inhibition. Native session monitors + report lock state and Running/QueryEnd/Ending transitions. Ending queries wait + for acknowledgements for up to one second. Power confirmations list blockers + and recheck them before acting; authentication failure cancels the query. + UserSwitch is recorded, but Ferese currently has no user-switch operation. - **ScreenCast:** monitor sharing with a native picker and PipeWire streams. See [screen sharing](screen-sharing.md) for lifetime and recording details. @@ -70,11 +76,11 @@ Matching D-Bus signatures does not establish complete feature parity. | Background | Native application state, per-instance consent and XDG autostart | | Usb | Native consent; standard frontend handles enumeration and device descriptors | | 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 | +| RemoteDesktop | Deferred: authorized input injection and EIS transport | +| Clipboard | Deferred with RemoteDesktop | | InputCapture | Missing zones, pointer barriers and EIS transport | | Lockdown | Native seven-property policy provider with persisted configuration and change signals | -| Inhibit | GTK's GNOME-session/ScreenSaver integration does not provide complete Ferese session inhibition | +| Inhibit | Native inhibitors and session monitors; UserSwitch recorded until a switch operation exists | The frontend supplies additional APIs such as OpenURI, network monitoring, document export and permission storage. Adding a similarly named backend service @@ -117,3 +123,13 @@ XML, verifies appearance changes and invalid-save retention, and checks that sensitive methods reject callers outside the portal frontend. It does not open consent dialogs or capture the host desktop. Interactive consent, fractional-scale capture, polkit and hotplug still require a live session test. + +Check native inhibitor lifetimes, acknowledgement generations and session-ending +transitions against a mock logind in an isolated nested compositor: + +```sh +FERESE_TEST_INHIBIT=1 python3 scripts/tests/test_inhibit_isolated.py +``` + +The shell now requires version 4 of Ferese's shell protocol; rebuild the shell +and compositor together when upgrading. diff --git a/packaging/portal/ferese-portals.conf b/packaging/portal/ferese-portals.conf index 4618f86..ddd343d 100644 --- a/packaging/portal/ferese-portals.conf +++ b/packaging/portal/ferese-portals.conf @@ -8,3 +8,4 @@ org.freedesktop.impl.portal.Background=ferese; org.freedesktop.impl.portal.Usb=ferese; org.freedesktop.impl.portal.Lockdown=ferese; org.freedesktop.impl.portal.GlobalShortcuts=ferese; +org.freedesktop.impl.portal.Inhibit=ferese; diff --git a/packaging/portal/ferese.portal b/packaging/portal/ferese.portal index 79c0af6..8210c5d 100644 --- a/packaging/portal/ferese.portal +++ b/packaging/portal/ferese.portal @@ -1,4 +1,4 @@ [portal] DBusName=org.freedesktop.impl.portal.desktop.ferese -Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Usb;org.freedesktop.impl.portal.Lockdown;org.freedesktop.impl.portal.GlobalShortcuts; +Interfaces=org.freedesktop.impl.portal.ScreenCast;org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Screenshot;org.freedesktop.impl.portal.Wallpaper;org.freedesktop.impl.portal.Background;org.freedesktop.impl.portal.Usb;org.freedesktop.impl.portal.Lockdown;org.freedesktop.impl.portal.GlobalShortcuts;org.freedesktop.impl.portal.Inhibit; UseIn=Ferese; diff --git a/portal/Cargo.toml b/portal/Cargo.toml index a10e6b8..ca60003 100644 --- a/portal/Cargo.toml +++ b/portal/Cargo.toml @@ -31,7 +31,7 @@ memmap2 = "0.9" pipewire = { version = "0.10.1", features = ["v0_3_65"] } serde.workspace = true serde_json.workspace = true -tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "process", "io-util"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync", "process", "io-util", "net"] } wayland-client.workspace = true wayland-protocols = { version = "0.32", features = ["client", "unstable"] } wayland-protocols-wlr.workspace = true diff --git a/portal/src/backend.rs b/portal/src/backend.rs index d745f1b..3479dbd 100644 --- a/portal/src/backend.rs +++ b/portal/src/backend.rs @@ -670,6 +670,7 @@ pub async fn run() -> Result<(), Box> { let background = crate::permissions::Background::new(requests.clone()); let lockdown = crate::lockdown::Lockdown::new(); let shortcuts = crate::shortcuts::GlobalShortcuts::new(requests.clone()); + let inhibit = crate::inhibit::Inhibit::default(); let connection = zbus::connection::Builder::session()? .name("org.freedesktop.impl.portal.desktop.ferese")? .serve_at(PATH, backend.clone())? @@ -680,6 +681,7 @@ pub async fn run() -> Result<(), Box> { .serve_at(PATH, background.clone())? .serve_at(PATH, lockdown.clone())? .serve_at(PATH, shortcuts.clone())? + .serve_at(PATH, inhibit.clone())? .serve_at( "/org/ferese/ScreenRecorder", RecorderControl(backend.clone()), @@ -689,6 +691,8 @@ pub async fn run() -> Result<(), Box> { tokio::spawn(settings.watch(connection.clone())); tokio::spawn(background.watch(connection.clone())); tokio::spawn(lockdown.watch(connection.clone())); + tokio::spawn(inhibit.clone().watch(connection.clone())); + tokio::spawn(inhibit.clone().watch_logind()); // Frontend death must revoke every stream, even if Session.Close never arrives. loop { tokio::time::sleep(Duration::from_secs(1)).await; @@ -702,6 +706,7 @@ pub async fn run() -> Result<(), Box> { }; requests.revoke_stale(owner.as_deref()).await; shortcuts.revoke_stale(&connection, owner.as_deref()).await; + inhibit.revoke_stale(&connection, owner.as_deref()).await; let stale: Vec<_> = backend .sessions .lock() diff --git a/portal/src/bridge.rs b/portal/src/bridge.rs new file mode 100644 index 0000000..b4ffb2a --- /dev/null +++ b/portal/src/bridge.rs @@ -0,0 +1,103 @@ +use serde_json::Value as Json; +use std::{os::unix::net::UnixStream, sync::Arc, time::Duration}; + +#[derive(Clone)] +pub(crate) struct Bridge(Arc); + +struct BridgeInner { + stream: std::sync::Mutex, + shutdown: UnixStream, +} + +impl Bridge { + pub(crate) 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, + }))) + } + + pub(crate) async fn call(&self, command: &'static str, args: Json) -> Result { + self.request(command, args, false).await + } + + pub(crate) async fn wait(&self, since: u64) -> Result { + self.request("session-watch", serde_json::json!({"since": since}), true) + .await + } + + async fn request(&self, command: &'static str, args: Json, wait: bool) -> Result { + let bridge = self.clone(); + tokio::task::spawn_blocking(move || { + let mut stream = bridge + .0 + .stream + .lock() + .map_err(|_| "Compositor connection failed")?; + stream + .set_read_timeout(if wait { + None + } else { + Some(Duration::from_secs(2)) + }) + .map_err(|error| error.to_string())?; + 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 compositor response".into()) + }) + .await + .map_err(|error| error.to_string())? + } + + pub(crate) async fn closed(&self) { + use std::io::Read; + let Ok(reader) = self.0.shutdown.try_clone() else { + return; + }; + if reader.set_nonblocking(true).is_err() { + return; + } + let Ok(reader) = tokio::io::unix::AsyncFd::new(reader) else { + return; + }; + loop { + let Ok(mut ready) = reader.readable().await else { + return; + }; + match ready.try_io(|reader| { + let mut socket = reader.get_ref(); + socket.read(&mut [0_u8; 1]) + }) { + Ok(_) => return, + Err(_) => continue, + } + } + } + + pub(crate) fn close(&self) { + let _ = self.0.shutdown.shutdown(std::net::Shutdown::Both); + } +} diff --git a/portal/src/desktop.rs b/portal/src/desktop.rs index 21746d2..ad2f1e2 100644 --- a/portal/src/desktop.rs +++ b/portal/src/desktop.rs @@ -22,10 +22,20 @@ const PATH: &str = "/org/freedesktop/portal/desktop"; const MAX_IMAGE_BYTES: u64 = 128 * 1024 * 1024; type Reply = (u32, Options); -#[derive(Clone, Default)] -pub(crate) struct Requests(Arc)>>>); +#[derive(Clone)] +pub(crate) struct Requests(Arc)>>>, usize); + +impl Default for Requests { + fn default() -> Self { + Self::with_limit(8) + } +} impl Requests { + pub(crate) fn with_limit(limit: usize) -> Self { + Self(Default::default(), limit) + } + pub(crate) async fn revoke_stale(&self, owner: Option<&str>) { for (request_owner, cancel) in self.0.lock().await.values() { if Some(request_owner.as_str()) != owner { @@ -45,7 +55,7 @@ impl Requests { return Err(zbus::fdo::Error::InvalidArgs("Invalid request path".into())); } let mut pending = self.0.lock().await; - if pending.len() >= 8 || pending.contains_key(handle.as_str()) { + if pending.len() >= self.1 || pending.contains_key(handle.as_str()) { return Err(zbus::fdo::Error::LimitsExceeded( "Request limit reached".into(), )); diff --git a/portal/src/inhibit.rs b/portal/src/inhibit.rs new file mode 100644 index 0000000..0970c6f --- /dev/null +++ b/portal/src/inhibit.rs @@ -0,0 +1,511 @@ +use std::{ + collections::HashMap, + sync::{Arc, atomic::Ordering}, + time::Duration, +}; +use tokio::sync::{Mutex, Notify}; +use zbus::{ + Connection, + message::Header, + object_server::SignalEmitter, + zvariant::{OwnedFd, OwnedObjectPath, Value}, +}; + +use crate::{ + backend::{Cancel, Options, authorize}, + bridge::Bridge, + desktop::Requests, +}; + +const PATH: &str = "/org/freedesktop/portal/desktop"; + +struct Monitor { + owner: String, + cancel: Arc, + last: Mutex>, + bridge: Bridge, + native_owner: u64, +} + +#[derive(Clone)] +pub(crate) struct Inhibit { + requests: Requests, + monitors: Arc>>>, + changed: Arc, +} + +impl Default for Inhibit { + fn default() -> Self { + Self { + requests: Requests::with_limit(24), + monitors: Default::default(), + changed: Default::default(), + } + } +} + +impl Inhibit { + pub(crate) async fn revoke_stale(&self, connection: &Connection, owner: Option<&str>) { + self.requests.revoke_stale(owner).await; + let stale = self + .monitors + .lock() + .await + .iter() + .filter(|(_, monitor)| Some(monitor.owner.as_str()) != owner) + .map(|(path, _)| path.clone()) + .collect::>(); + for path in stale { + self.end(connection, &path).await; + } + } + + async fn end(&self, connection: &Connection, path: &str) { + if let Some(monitor) = self.monitors.lock().await.remove(path) { + let _state = monitor.last.lock().await; + monitor.cancel.stop(); + monitor.bridge.close(); + if let Ok(emitter) = SignalEmitter::new(connection, path) { + let _ = MonitorObject::closed(&emitter).await; + } + let _ = connection + .object_server() + .remove::(path) + .await; + self.changed.notify_one(); + } + } + + pub(crate) async fn watch(self, connection: Connection) { + let mut bridge: Option = None; + let mut since = None; + loop { + if self.monitors.lock().await.is_empty() { + if let Some(bridge) = bridge.take() { + bridge.close(); + } + since = None; + self.changed.notified().await; + continue; + } + if bridge.is_none() { + bridge = Bridge::connect().ok(); + } + let result = if let Some(active) = &bridge { + tokio::select! { + biased; + _ = self.changed.notified() => { active.close(); bridge = None; since = None; continue; } + state = async { + match since { Some(revision) => active.wait(revision).await, None => active.call("get-session-state", serde_json::json!({})).await } + } => state, + } + } else { + Err("Session monitor is unavailable".into()) + }; + let monitors = self + .monitors + .lock() + .await + .iter() + .map(|(path, monitor)| (path.clone(), monitor.clone())) + .collect::>(); + match result { + Ok(state) => { + since = state["revision"].as_u64(); + for (path, monitor) in monitors { + if !state["monitor-owners"].as_array().is_some_and(|owners| { + owners + .iter() + .any(|owner| owner.as_u64() == Some(monitor.native_owner)) + }) { + self.end(&connection, &path).await; + continue; + } + let mut last = monitor.last.lock().await; + if monitor.cancel.stopped.load(Ordering::SeqCst) { + continue; + } + let transitions = match *last { + None => vec![state.clone()], + Some((_, _, revision, _)) => { + let events = + state["transitions"].as_array().cloned().unwrap_or_default(); + if events + .first() + .and_then(|event| event["revision"].as_u64()) + .is_some_and(|first| first > revision.saturating_add(1)) + { + drop(last); + self.end(&connection, &path).await; + continue; + } + events + .into_iter() + .filter(|event| { + event["revision"] + .as_u64() + .is_some_and(|next| next > revision) + }) + .collect() + } + }; + for event in transitions { + let locked = event["screensaver-active"].as_bool().unwrap_or(false); + let phase = event["session-state"].as_u64().unwrap_or(1) as u32; + let revision = event["revision"].as_u64().unwrap_or(0); + let token = event["query-token"].as_u64().unwrap_or(0) as u32; + let changed = last.as_ref().is_none_or(|old| { + old.0 != locked || old.1 != phase || (phase == 2 && old.3 != token) + }); + if changed { + let values = HashMap::from([ + ( + "screensaver-active".into(), + Value::from(locked).try_into().unwrap(), + ), + ( + "session-state".into(), + Value::from(phase).try_into().unwrap(), + ), + ]); + if let Ok(emitter) = SignalEmitter::new(&connection, PATH) { + if Self::state_changed( + &emitter, + OwnedObjectPath::try_from(path.clone()).unwrap(), + values, + ) + .await + .is_err() + { + break; + } + } + } + *last = Some((locked, phase, revision, token)); + } + } + } + Err(error) => { + eprintln!("ferese-portal: {error}"); + for (path, _) in monitors { + self.end(&connection, &path).await; + } + if let Some(bridge) = bridge.take() { + bridge.close(); + } + since = None; + } + } + } + } + + pub(crate) async fn watch_logind(self) { + use futures_util::StreamExt; + loop { + let result: Result<(), String> = async { + let connection = Connection::system() + .await + .map_err(|error| error.to_string())?; + let proxy = zbus::Proxy::new( + &connection, + "org.freedesktop.login1", + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + ) + .await + .map_err(|error| error.to_string())?; + let mut signals = proxy + .receive_signal("PrepareForShutdown") + .await + .map_err(|error| error.to_string())?; + while let Some(message) = signals.next().await { + let (ending,): (bool,) = message + .body() + .deserialize() + .map_err(|error| error.to_string())?; + if let Ok(bridge) = Bridge::connect() { + let _ = bridge + .call( + "logind-session-ending", + serde_json::json!({"ending": ending}), + ) + .await; + bridge.close(); + } + } + Ok(()) + } + .await; + if let Err(error) = result { + eprintln!("ferese-portal: logind monitor unavailable: {error}"); + } + tokio::time::sleep(Duration::from_secs(10)).await; + } + } +} + +fn flags_what(flags: u32) -> zbus::fdo::Result { + if flags == 0 || flags & !15 != 0 { + return Err(zbus::fdo::Error::InvalidArgs( + "Invalid inhibition flags".into(), + )); + } + let mut what = Vec::new(); + if flags & 1 != 0 { + what.push("shutdown"); + } + if flags & 4 != 0 { + what.push("sleep"); + } + if flags & 8 != 0 { + what.push("idle"); + } + Ok(what.join(":")) +} + +async fn login_inhibitor(what: &str, app: &str, reason: &str) -> Result, String> { + if what.is_empty() { + return Ok(None); + } + tokio::time::timeout(Duration::from_secs(5), async { + let connection = Connection::system() + .await + .map_err(|error| error.to_string())?; + let proxy = zbus::Proxy::new( + &connection, + "org.freedesktop.login1", + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + ) + .await + .map_err(|error| error.to_string())?; + let fd: OwnedFd = proxy + .call("Inhibit", &(what, app, reason, "block")) + .await + .map_err(|error| error.to_string())?; + Ok(Some(fd)) + }) + .await + .map_err(|_| "Session inhibition timed out".to_owned())? +} + +#[zbus::interface(name = "org.freedesktop.impl.portal.Inhibit")] +impl Inhibit { + async fn inhibit( + &self, + handle: OwnedObjectPath, + app_id: String, + _window: String, + flags: u32, + options: Options, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + authorize(connection, &header).await?; + let what = flags_what(flags)?; + let reason = options + .get("reason") + .map(|value| <&str>::try_from(value).map(str::to_owned)) + .transpose() + .map_err(|_| zbus::fdo::Error::InvalidArgs("Invalid inhibition reason".into()))? + .unwrap_or_else(|| "Application requested session inhibition".into()); + if app_id.len() > 512 + || reason.len() > 1024 + || app_id.chars().chain(reason.chars()).any(char::is_control) + { + return Err(zbus::fdo::Error::InvalidArgs( + "Invalid application or reason".into(), + )); + } + let cancel = self.requests.begin(connection, &header, &handle).await?; + let bridge = match Bridge::connect() { + Ok(bridge) => bridge, + Err(error) => { + self.requests.end(connection, &handle).await; + return Err(zbus::fdo::Error::Failed(error)); + } + }; + let acquired = tokio::select! { + biased; + _ = cancel.wait() => Err("Inhibition was cancelled".to_owned()), + result = async { + let fd = login_inhibitor(&what, &app_id, &reason).await?; + bridge.call("portal-inhibit", serde_json::json!({"flags": flags, "app": app_id, "reason": reason})).await?; + Ok(fd) + } => result, + }; + match acquired { + Ok(fd) if !cancel.stopped.load(Ordering::SeqCst) => { + let requests = self.requests.clone(); + let connection = connection.clone(); + tokio::spawn(async move { + tokio::select! { _ = cancel.wait() => {}, _ = bridge.closed() => {} } + cancel.stop(); + bridge.close(); + drop(fd); + requests.end(&connection, &handle).await; + }); + Ok(()) + } + result => { + bridge.close(); + self.requests.end(connection, &handle).await; + Err(zbus::fdo::Error::Failed( + result + .err() + .unwrap_or_else(|| "Inhibition was cancelled".into()), + )) + } + } + } + + async fn create_monitor( + &self, + handle: OwnedObjectPath, + session_handle: OwnedObjectPath, + app_id: String, + _window: String, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result { + let owner = authorize(connection, &header).await?; + if !session_handle + .as_str() + .starts_with(&format!("{PATH}/session/")) + || session_handle.as_str().len() > 512 + || app_id.len() > 512 + { + return Err(zbus::fdo::Error::InvalidArgs( + "Invalid session monitor".into(), + )); + } + let cancel = self.requests.begin(connection, &header, &handle).await?; + let mut monitors = self.monitors.lock().await; + let result = if monitors.len() >= 8 + || monitors.contains_key(session_handle.as_str()) + || cancel.stopped.load(Ordering::SeqCst) + { + Ok(2) + } else { + let attempt = async { + let bridge = Bridge::connect().map_err(zbus::fdo::Error::Failed)?; + let result = tokio::select! { + biased; + _ = cancel.wait() => Err("Monitor creation was cancelled".into()), + result = bridge.call("portal-monitor-register", serde_json::json!({})) => result, + }; + let native = match result { Ok(native) => native, Err(error) => { bridge.close(); return Err(zbus::fdo::Error::Failed(error)); } }; + let Some(native_owner) = native["monitor-owner"].as_u64() else { + bridge.close(); + return Err(zbus::fdo::Error::Failed("Missing monitor owner".into())); + }; + let exported = connection.object_server().at(&session_handle, MonitorObject { + backend: self.clone(), path: session_handle.to_string(), owner: owner.clone(), cancel: cancel.clone(), + }).await; + match exported { + Ok(true) if !cancel.stopped.load(Ordering::SeqCst) => { + monitors.insert(session_handle.to_string(), Arc::new(Monitor { + owner, cancel: cancel.clone(), last: Mutex::new(None), bridge, + native_owner, + })); + self.changed.notify_one(); + Ok(0) + } + result => { + bridge.close(); + if matches!(result, Ok(true)) { let _ = connection.object_server().remove::(&session_handle).await; } + result.map(|_| if cancel.stopped.load(Ordering::SeqCst) { 1 } else { 2 }).map_err(Into::into) + } + } + }.await; + attempt + }; + drop(monitors); + self.requests.end(connection, &handle).await; + result + } + + async fn query_end_response( + &self, + session_handle: OwnedObjectPath, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + ) -> zbus::fdo::Result<()> { + let owner = authorize(connection, &header).await?; + let monitor = self + .monitors + .lock() + .await + .get(session_handle.as_str()) + .filter(|monitor| monitor.owner == owner) + .cloned() + .ok_or_else(|| zbus::fdo::Error::AccessDenied("Not your session monitor".into()))?; + let last = monitor.last.lock().await; + if monitor.cancel.stopped.load(Ordering::SeqCst) { + return Err(zbus::fdo::Error::AccessDenied( + "Session monitor closed".into(), + )); + } + if let Some((_, 2, _, token)) = *last { + monitor + .bridge + .call("portal-monitor-ack", serde_json::json!({"token": token})) + .await + .map_err(zbus::fdo::Error::Failed)?; + } + Ok(()) + } + + #[zbus(signal)] + async fn state_changed( + emitter: &SignalEmitter<'_>, + session_handle: OwnedObjectPath, + state: Options, + ) -> zbus::Result<()>; +} + +struct MonitorObject { + backend: Inhibit, + path: String, + owner: String, + cancel: Arc, +} + +#[zbus::interface(name = "org.freedesktop.impl.portal.Session")] +impl MonitorObject { + #[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 authorize(connection, &header).await? != self.owner { + return Err(zbus::fdo::Error::AccessDenied( + "Not your session monitor".into(), + )); + } + self.cancel.stop(); + self.backend.end(connection, &self.path).await; + Ok(()) + } + + #[zbus(signal)] + async fn closed(emitter: &SignalEmitter<'_>) -> zbus::Result<()>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn logind_flags_are_validated_and_mapped_independently() { + assert_eq!(flags_what(1 | 4 | 8).unwrap(), "shutdown:sleep:idle"); + assert_eq!(flags_what(2).unwrap(), ""); + assert!(flags_what(0).is_err()); + assert!(flags_what(16).is_err()); + } +} diff --git a/portal/src/main.rs b/portal/src/main.rs index aa81bd6..72113b3 100644 --- a/portal/src/main.rs +++ b/portal/src/main.rs @@ -1,7 +1,9 @@ mod backend; +mod bridge; mod capture; mod consent; mod desktop; +mod inhibit; mod lockdown; mod parent; mod permissions; diff --git a/portal/src/shortcuts.rs b/portal/src/shortcuts.rs index 4891406..94eebe5 100644 --- a/portal/src/shortcuts.rs +++ b/portal/src/shortcuts.rs @@ -1,9 +1,6 @@ -use std::{ - collections::HashMap, io::Read, os::unix::net::UnixStream, process::Stdio, sync::Arc, - time::Duration, -}; +use std::{collections::HashMap, io::Read, process::Stdio, sync::Arc, time::Duration}; -use serde_json::{Value as Json, json}; +use serde_json::json; use tokio::{io::AsyncWriteExt, sync::Mutex}; use zbus::{ Connection, @@ -14,6 +11,7 @@ use zbus::{ use crate::{ backend::{Cancel, Options, authorize}, + bridge::Bridge, consent::{Prompt, ShortcutField}, desktop::Requests, }; @@ -21,66 +19,6 @@ use crate::{ 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, @@ -177,6 +115,7 @@ impl GlobalShortcuts { }; 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; @@ -187,7 +126,9 @@ impl GlobalShortcuts { let Some(selected) = selected else { return Ok(None); }; + validate_selection(&fields, &selected)?; + let registration = selected .iter() .filter(|field| !field.trigger.trim().is_empty()) @@ -199,6 +140,7 @@ impl GlobalShortcuts { { Ok(_) => { let mut state = session.state.lock().await; + if session .cancel .stopped @@ -218,6 +160,7 @@ impl GlobalShortcuts { .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 @@ -226,16 +169,20 @@ impl GlobalShortcuts { 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(); @@ -244,6 +191,7 @@ impl GlobalShortcuts { backend.watch(connection, path, session, bridge).await; }); } + return Ok(Some(results(&selected))); } Err(error) => { diff --git a/protocols/ferese-shell-v1.xml b/protocols/ferese-shell-v1.xml index bbaba51..1b3cf29 100644 --- a/protocols/ferese-shell-v1.xml +++ b/protocols/ferese-shell-v1.xml @@ -22,7 +22,7 @@ DEALINGS IN THE SOFTWARE. - + This private global is visible only to clients granted the shell-control capability. It does not assign a Wayland surface role. @@ -35,7 +35,7 @@ - + Window, workspace, and output identifiers are unsigned 64-bit values encoded as high and low 32-bit words. Each snapshot atomically replaces @@ -156,5 +156,11 @@ + + + + + + diff --git a/scripts/tests/test_inhibit_isolated.py b/scripts/tests/test_inhibit_isolated.py new file mode 100644 index 0000000..5c3a21f --- /dev/null +++ b/scripts/tests/test_inhibit_isolated.py @@ -0,0 +1,234 @@ +"""Exercise real inhibitor FD lifetimes and session handshake on an isolated bus. + +FERESE_TEST_INHIBIT=1 python3 scripts/tests/test_inhibit_isolated.py +Uses a nested compositor and mock logind; never suspends or ends the host session. +""" +import json +import os +from pathlib import Path +import signal +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time +import unittest + +REPO = Path(__file__).resolve().parents[2] +NAME = "org.freedesktop.impl.portal.desktop.ferese" +PATH = "/org/freedesktop/portal/desktop" +INTERFACE = "org.freedesktop.impl.portal.Inhibit" + + +@unittest.skipUnless(os.environ.get("FERESE_TEST_INHIBIT") == "1", "requires a Wayland host") +class InhibitIntegration(unittest.TestCase): + def test_private_bus_lifetimes_and_query_end(self): + result = subprocess.run(["dbus-run-session", "--", sys.executable, __file__, "--private"], capture_output=True, text=True, timeout=40) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +class IPC: + def __init__(self, path): + self.socket = socket.socket(socket.AF_UNIX) + self.socket.settimeout(3) + self.socket.connect(str(path)) + + def call(self, command, args=None): + request = json.dumps({"version": 1, "id": 1, "type": "command", "command": command, "args": args or {}}).encode() + self.socket.sendall(struct.pack(">I", len(request)) + request) + def read(size): + result = b"" + while len(result) < size: + chunk = self.socket.recv(size - len(result)) + if not chunk: + raise EOFError("IPC connection closed") + result += chunk + return result + response = json.loads(read(struct.unpack(">I", read(4))[0])) + if response.get("error"): + raise ValueError(response["error"]) + return response["result"] + + def close(self): + self.socket.close() + + +def private_test(): + from gi.repository import Gio, GLib + processes = [] + pipes = [] + pipe_lock = threading.Lock() + ready = threading.Event() + login_loop = None + login_connection = None + manager_xml = """ + + + + """ + + def logind(): + nonlocal login_loop, login_connection + context = GLib.MainContext.new() + context.push_thread_default() + login_connection = Gio.DBusConnection.new_for_address_sync(os.environ["DBUS_SESSION_BUS_ADDRESS"], Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None) + login_connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", GLib.Variant("(su)", ("org.freedesktop.login1", 0)), None, Gio.DBusCallFlags.NONE, 2000, None) + def method(connection, sender, object_path, interface, name, params, invocation): + if name == "Inhibit": + what, app, reason, mode = params.unpack() + reader, writer = os.pipe2(os.O_CLOEXEC | os.O_NONBLOCK) + descriptors = Gio.UnixFDList.new() + index = descriptors.append(writer) + with pipe_lock: + pipes.append((reader, what, app, reason, mode)) + invocation.return_value_with_unix_fd_list(GLib.Variant("(h)", (index,)), descriptors) + os.close(writer) + else: + rows = [] + with pipe_lock: + for reader, what, app, reason, mode in pipes: + try: + if os.read(reader, 1) == b"": + continue + except BlockingIOError: + pass + rows.append((what, app, reason, mode, os.getuid(), os.getpid())) + invocation.return_value(GLib.Variant("(a(ssssuu))", (rows,))) + login_connection.register_object("/org/freedesktop/login1", Gio.DBusNodeInfo.new_for_xml(manager_xml).interfaces[0], method, None, None) + login_loop = GLib.MainLoop.new(context, False) + ready.set() + login_loop.run() + context.pop_thread_default() + + thread = threading.Thread(target=logind, daemon=True) + thread.start() + assert ready.wait(3), "Mock logind did not start" + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + + def call(interface, method, params=None, signature=None, path=PATH): + return bus.call_sync(NAME, path, interface, method, params, GLib.VariantType.new(signature) if signature else None, Gio.DBusCallFlags.NO_AUTO_START, 5000, None).unpack() + + def wait_for(check, message): + deadline = time.monotonic() + 4 + while time.monotonic() < deadline: + while GLib.MainContext.default().pending(): + GLib.MainContext.default().iteration(False) + if check(): + return + time.sleep(0.01) + raise AssertionError(message) + + def active_fd(index): + with pipe_lock: + try: + return os.read(pipes[index][0], 1) != b"" + except BlockingIOError: + return True + + with tempfile.TemporaryDirectory(prefix="ferese-inhibit-") 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("status { keybinding-guide #false; }\n") + display = Path(os.environ["WAYLAND_DISPLAY"]) + if not display.is_absolute(): + display = Path(os.environ["XDG_RUNTIME_DIR"]) / display + env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), WAYLAND_DISPLAY=str(display), DBUS_SYSTEM_BUS_ADDRESS=os.environ["DBUS_SESSION_BUS_ADDRESS"]) + env.pop("WAYLAND_SOCKET", None) + log = (root / "test.log").open("w") + try: + compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True) + processes.append(compositor) + deadline = time.monotonic() + 10 + while not (runtime / "ferese/control.sock").exists(): + assert compositor.poll() is None and time.monotonic() < deadline, (root / "test.log").read_text() + time.sleep(0.02) + backend = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese")], env=env, stdout=log, stderr=log, start_new_session=True) + processes.append(backend) + deadline = time.monotonic() + 10 + while True: + try: + call("org.freedesktop.DBus.Introspectable", "Introspect", signature="(s)") + break + except GLib.Error: + assert backend.poll() is None and time.monotonic() < deadline, (root / "test.log").read_text() + time.sleep(0.02) + request = PATH + "/request/test/inhibit" + try: + call(INTERFACE, "Inhibit", GLib.Variant("(ossua{sv})", (request, "test.media", "", 8, {}))) + raise AssertionError("Untrusted inhibition was accepted") + except GLib.Error as error: + assert "AccessDenied" in str(error) + bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), None, Gio.DBusCallFlags.NONE, 2000, None) + admin = IPC(runtime / "ferese/control.sock") + def inhibit(path, flags): + call(INTERFACE, "Inhibit", GLib.Variant("(ossua{sv})", (path, "test.media", "", flags, {"reason": GLib.Variant("s", "Playing media")}))) + inhibit(request, 8) + assert active_fd(0) + assert admin.call("get-session-state")["inhibitors"][0]["flags"] == 8 + call("org.freedesktop.impl.portal.Request", "Close", path=request) + wait_for(lambda: not active_fd(0) and not admin.call("get-session-state")["inhibitors"], "Close retained inhibition") + session = PATH + "/session/test/monitor" + changes = [] + closed = [] + bus.signal_subscribe(NAME, INTERFACE, "StateChanged", PATH, None, Gio.DBusSignalFlags.NONE, lambda *args: changes.append(args[-1].unpack()[1])) + bus.signal_subscribe(NAME, "org.freedesktop.impl.portal.Session", "Closed", session, None, Gio.DBusSignalFlags.NONE, lambda *args: closed.append(True)) + assert call(INTERFACE, "CreateMonitor", GLib.Variant("(ooss)", (PATH + "/request/test/monitor", session, "test.media", "")), "(u)") == (0,) + wait_for(lambda: any(value["session-state"] == 1 for value in changes), "Missing initial Running") + assert call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.Session", "version")), "(v)", session) == (1,) + query = IPC(runtime / "ferese/control.sock") + state = query.call("begin-session-end") + assert state["query-ready"] is False + wait_for(lambda: any(value["session-state"] == 2 for value in changes), "Missing QueryEnd") + call(INTERFACE, "QueryEndResponse", GLib.Variant("(o)", (session,))) + assert admin.call("get-session-state")["query-ready"] is True + approved = state["inhibitor-revision"] + inhibit(request, 1) + try: + query.call("commit-session-end", {"token": state["query-token"], "inhibitor-revision": approved, "force": True}) + raise AssertionError("Late inhibitor bypassed confirmation") + except ValueError: + pass + current = admin.call("get-session-state") + query.call("validate-session-end", {"token": state["query-token"], "inhibitor-revision": current["inhibitor-revision"], "force": True}) + assert admin.call("get-session-state")["session-state"] == 2 + query.call("commit-session-end", {"token": state["query-token"], "inhibitor-revision": current["inhibitor-revision"], "force": True}) + wait_for(lambda: any(value["session-state"] == 3 for value in changes), "Missing Ending") + query.call("cancel-session-end", {"token": state["query-token"]}) + wait_for(lambda: changes[-1]["session-state"] == 1, "Missing cancelled end state") + query.close() + call("org.freedesktop.impl.portal.Session", "Close", path=session) + wait_for(lambda: len(closed) == 1, "Missing Closed") + count = len(changes) + login_connection.emit_signal(None, "/org/freedesktop/login1", "org.freedesktop.login1.Manager", "PrepareForShutdown", GLib.Variant("(b)", (True,))) + wait_for(lambda: admin.call("get-session-state")["session-state"] == 3, "Missing external shutdown state") + time.sleep(0.1) + while GLib.MainContext.default().pending(): + GLib.MainContext.default().iteration(False) + assert len(changes) == count, "StateChanged appeared after Closed" + compositor.terminate() + compositor.wait(timeout=5) + wait_for(lambda: not active_fd(1), "Compositor death retained logind inhibitor FD") + admin.close() + print("Inhibit FD, monitor, ACK, late consent and disconnect checks passed") + finally: + for process in reversed(processes): + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + process.wait(timeout=5) + log.close() + login_loop.quit() + thread.join(timeout=2) + for pipe, *_ in pipes: + os.close(pipe) + + +if __name__ == "__main__": + if "--private" in sys.argv: + private_test() + else: + unittest.main() diff --git a/scripts/tests/test_portal_contracts.py b/scripts/tests/test_portal_contracts.py index a0bc998..48c4ff2 100644 --- a/scripts/tests/test_portal_contracts.py +++ b/scripts/tests/test_portal_contracts.py @@ -54,7 +54,7 @@ def private_bus_checks(): raise AssertionError((root / "backend.log").read_text()) time.sleep(0.05) node = ET.fromstring(xml) - for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown", "GlobalShortcuts"): + for name in ("ScreenCast", "Settings", "Screenshot", "Wallpaper", "Background", "Usb", "Lockdown", "GlobalShortcuts", "Inhibit"): 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") diff --git a/shell/ferese-shell/src/compositor_ipc.rs b/shell/ferese-shell/src/compositor_ipc.rs new file mode 100644 index 0000000..0bc08e4 --- /dev/null +++ b/shell/ferese-shell/src/compositor_ipc.rs @@ -0,0 +1,221 @@ +use serde_json::{Value, json}; +use std::{ + os::unix::net::UnixStream, + path::PathBuf, + time::{Duration, Instant}, +}; + +pub(super) fn call(command: &str) -> Result { + request(command, json!({})) +} + +#[derive(Debug)] +struct SessionConnection { + stream: std::sync::Mutex, +} + +impl SessionConnection { + fn connect() -> Result { + let path = std::env::var_os("XDG_RUNTIME_DIR") + .map(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())?; + Ok(Self { + stream: std::sync::Mutex::new(stream), + }) + } + + fn request(&self, command: &str, args: Value) -> Result { + let mut stream = self + .stream + .lock() + .map_err(|_| "Compositor 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 compositor response".into()) + } +} + +fn request(command: &str, args: Value) -> Result { + SessionConnection::connect()?.request(command, args) +} + +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub(super) enum Mode { + #[default] + Guide, + Logout, + Shutdown, + Suspend, +} + +#[derive(Clone, Debug, Default)] +pub(super) struct Approval { + pub reasons: Vec, + pub revision: u32, + pub token: u32, + mode: Mode, + external: Vec, + connection: Option>, +} + +impl Approval { + pub fn force(&self) -> bool { + !self.reasons.is_empty() + } + + pub fn cancel(&self) { + if self.mode == Mode::Shutdown { + let _ = request("cancel-session-end", json!({"token": self.token})); + } + } + + pub fn prepare(&self) -> Result<(), String> { + if matches!(self.mode, Mode::Shutdown | Mode::Suspend) + && login_inhibitors(self.mode)? != self.external + { + return Err( + "Applications changed their inhibitors; review the confirmation again".into(), + ); + } + if self.mode == Mode::Shutdown { + self.connection.as_ref().ok_or("Missing session-ending lease")?.request( + "validate-session-end", + json!({"token": self.token, "inhibitor-revision": self.revision, "force": self.force()}), + )?; + } else if self.mode == Mode::Suspend { + let state = call("get-session-state")?; + if state["inhibitor-revision"].as_u64() != Some(u64::from(self.revision)) { + return Err( + "Applications changed their inhibitors; review the confirmation again".into(), + ); + } + } + Ok(()) + } +} + +fn login_inhibitors(mode: Mode) -> Result, String> { + if !matches!(mode, Mode::Shutdown | Mode::Suspend) { + return Ok(Vec::new()); + } + let connection = zbus::blocking::connection::Builder::system() + .map_err(|error| error.to_string())? + .method_timeout(Duration::from_secs(2)) + .build() + .map_err(|error| error.to_string())?; + let proxy = zbus::blocking::Proxy::new( + &connection, + "org.freedesktop.login1", + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + ) + .map_err(|error| error.to_string())?; + let inhibitors: Vec<(String, String, String, String, u32, u32)> = proxy + .call("ListInhibitors", &()) + .map_err(|error| error.to_string())?; + let what = if mode == Mode::Suspend { + "sleep" + } else { + "shutdown" + }; + let mut reasons = inhibitors + .into_iter() + .filter(|(flags, _, _, inhibition, _, _)| { + inhibition == "block" && flags.split(':').any(|flag| flag == what) + }) + .map(|(_, app, reason, _, _, _)| format!("{app}: {reason}")) + .collect::>(); + reasons.sort(); + reasons.dedup(); + Ok(reasons) +} + +pub(super) fn inhibitors(mode: Mode) -> Result { + let connection = if mode == Mode::Shutdown { + Some(std::sync::Arc::new(SessionConnection::connect()?)) + } else { + None + }; + let mut state = match &connection { + Some(connection) => connection.request("begin-session-end", json!({}))?, + None => call("get-session-state")?, + }; + let token = state["query-token"] + .as_u64() + .and_then(|token| u32::try_from(token).ok()) + .unwrap_or(0); + let result = (|| { + if matches!(mode, Mode::Logout | Mode::Shutdown) { + let deadline = Instant::now() + Duration::from_millis(1500); + while state["query-ready"] != true { + if state["session-state"] != 2 + || state["query-token"].as_u64() != Some(u64::from(token)) + || Instant::now() >= deadline + { + return Err("Session-ending request was cancelled or timed out".into()); + } + std::thread::sleep(Duration::from_millis(50)); + state = match &connection { + Some(connection) => connection.request("get-session-state", json!({}))?, + None => call("get-session-state")?, + }; + } + } + let flags = if mode == Mode::Suspend { 4 } else { 1 }; + let entries = state["inhibitors"] + .as_array() + .ok_or("Invalid inhibitor list")?; + let mut reasons = entries + .iter() + .filter(|entry| entry["flags"].as_u64().unwrap_or(0) & flags != 0) + .map(|entry| { + let app = entry["app"] + .as_str() + .filter(|app| !app.is_empty()) + .unwrap_or("Application"); + let reason = entry["reason"] + .as_str() + .unwrap_or("Requested session inhibition"); + format!("{app}: {reason}") + }) + .collect::>(); + let external = login_inhibitors(mode)?; + reasons.extend(external.iter().cloned()); + reasons.sort(); + reasons.dedup(); + Ok(Approval { + reasons, + revision: state["inhibitor-revision"] + .as_u64() + .and_then(|revision| u32::try_from(revision).ok()) + .ok_or("Missing inhibitor revision")?, + token, + mode, + external, + connection, + }) + })(); + if result.is_err() && mode == Mode::Shutdown { + let _ = request("cancel-session-end", json!({"token": token})); + } + result +} diff --git a/shell/ferese-shell/src/control.rs b/shell/ferese-shell/src/control.rs index 4b4bc81..d6c0f36 100644 --- a/shell/ferese-shell/src/control.rs +++ b/shell/ferese-shell/src/control.rs @@ -81,7 +81,7 @@ impl ShellControl { let connection = control_connection()?; let (globals, mut queue) = registry_queue_init::(&connection)?; let qh = queue.handle(); - let manager = globals.bind::(&qh, 1..=3, ())?; + let manager = globals.bind::(&qh, 4..=4, ())?; let shell = manager.get_shell(&qh, ()); let (sender, updates) = mpsc::channel(); let mut state = ControlState::new(sender); @@ -150,11 +150,10 @@ impl ShellControl { let _ = self.connection.flush(); } - pub(crate) fn confirm_logout(&self, serial: u32) { - if self.shell.version() >= 3 { - self.shell.confirm_logout(serial); - let _ = self.connection.flush(); - } + pub(crate) fn confirm_logout(&self, serial: u32, revision: u32, token: u32, force: bool) { + self.shell + .confirm_logout_with_inhibitors(serial, revision, token, u32::from(force)); + let _ = self.connection.flush(); } pub(crate) fn cancel_logout(&self, serial: u32) { diff --git a/shell/ferese-shell/src/keybinding_guide.rs b/shell/ferese-shell/src/keybinding_guide.rs index 3613432..9878175 100644 --- a/shell/ferese-shell/src/keybinding_guide.rs +++ b/shell/ferese-shell/src/keybinding_guide.rs @@ -1,5 +1,3 @@ -use std::{os::unix::net::UnixStream, path::PathBuf, time::Duration}; - #[derive(Clone, Debug, PartialEq, serde::Deserialize)] pub(super) struct Entry { pub keys: String, @@ -7,32 +5,9 @@ pub(super) struct Entry { } pub(super) fn load() -> Result, String> { - let path = std::env::var_os("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .ok_or("Missing runtime directory")? - .join("ferese/control.sock"); - let mut 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 request = ferese_ipc::Request { - version: ferese_ipc::VERSION, - id: 1, - kind: "command".into(), - command: "get-keybindings".into(), - args: serde_json::json!({}), - }; - 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); - } - let entries: Vec = serde_json::from_value(response.result.ok_or("Missing keybindings")?) - .map_err(|error| error.to_string())?; + let entries: Vec = + serde_json::from_value(crate::compositor_ipc::call("get-keybindings")?) + .map_err(|error| error.to_string())?; if entries.len() > 256 || entries .iter() diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 41fa5c8..35e9414 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -1,3 +1,4 @@ +mod compositor_ipc; mod config; mod control; mod keybinding_guide; @@ -256,6 +257,7 @@ enum Message { Control(status::Action), ShowGuide, GuideLoaded(Result, String>), + SystemInhibitors(window::Id, Result), ConfirmPower(status::Action), CancelPower, ExecutePower, @@ -690,6 +692,7 @@ impl cosmic::Application for FereseShell { } } } + Message::SystemInhibitors(id, result) => self.set_system_inhibitors(id, result), Message::ConfirmPower(action) => { if let Some(action) = system_modal::PowerAction::from_status(action) { return self.open_system_modal(action, None); diff --git a/shell/ferese-shell/src/status.rs b/shell/ferese-shell/src/status.rs index d60a3d3..c2ae785 100644 --- a/shell/ferese-shell/src/status.rs +++ b/shell/ferese-shell/src/status.rs @@ -523,14 +523,19 @@ fn notifications(bus: &mut StatusBus) -> Option { Some(Notifications { count, dnd }) } -pub(super) fn execute_power(action: Action) -> Result<(), String> { +pub(super) fn execute_power(action: Action, force: bool) -> Result<(), String> { let action = match action { Action::Poweroff => "poweroff", Action::Reboot => "reboot", Action::Suspend => "suspend", _ => return Err("Invalid power action".to_owned()), }; - run_with_timeout("systemctl", &[action], "120s").map(|_| ()) + let check = if force { + "--check-inhibitors=no" + } else { + "--check-inhibitors=yes" + }; + run_with_timeout("systemctl", &[check, action], "120s").map(|_| ()) } fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> { diff --git a/shell/ferese-shell/src/system_modal.rs b/shell/ferese-shell/src/system_modal.rs index eeeb28d..8609817 100644 --- a/shell/ferese-shell/src/system_modal.rs +++ b/shell/ferese-shell/src/system_modal.rs @@ -65,6 +65,7 @@ pub(super) struct SystemModal { surfaces: Vec, pub(super) motion: motion::PopupMotion, error: Option, + inhibitors: Option, } impl SystemModal { @@ -183,15 +184,65 @@ impl FereseShell { ); tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); } + let mode = match content { + Content::Power(PowerAction::Suspend) => Some(compositor_ipc::Mode::Suspend), + Content::Power(PowerAction::Logout(_)) => Some(compositor_ipc::Mode::Logout), + Content::Power(_) => Some(compositor_ipc::Mode::Shutdown), + Content::Guide(_) => None, + }; + if let Some(mode) = mode { + let id = surfaces[0].id; + tasks.push(Task::perform( + async move { + tokio::task::spawn_blocking(move || compositor_ipc::inhibitors(mode)) + .await + .map_err(|error| error.to_string()) + .and_then(|result| result) + }, + move |result| cosmic::Action::App(Message::SystemInhibitors(id, result)), + )); + } self.system_modal = Some(SystemModal { content, surfaces, motion: motion::PopupMotion::new(self.config.animations), error: None, + inhibitors: mode.is_none().then(compositor_ipc::Approval::default), }); Task::batch(tasks) } + pub(super) fn set_system_inhibitors( + &mut self, + id: window::Id, + result: Result, + ) -> Task { + if let Some(modal) = &mut self.system_modal + && modal.contains(id) + && matches!(modal.content, Content::Power(_)) + && !modal.motion.closing() + { + match result { + Ok(inhibitors) => modal.inhibitors = Some(inhibitors), + Err(error) => { + modal.error = Some(error); + } + } + } else if let Ok(approval) = result { + return Self::cancel_query(approval); + } + Task::none() + } + + fn cancel_query(approval: compositor_ipc::Approval) -> Task { + Task::perform( + async move { + let _ = tokio::task::spawn_blocking(move || approval.cancel()).await; + }, + |_| cosmic::Action::App(Message::AnimatePower), + ) + } + pub(super) fn attach_power_material( &mut self, id: window::Id, @@ -232,7 +283,13 @@ impl FereseShell { { control.cancel_logout(serial); } + let cancel = modal + .inhibitors + .take() + .map(Self::cancel_query) + .unwrap_or_else(Task::none); modal.motion.retarget(0.0, Instant::now()); + return Task::batch([cancel, self.animate_system_modal()]); } self.animate_system_modal() } @@ -259,12 +316,15 @@ impl FereseShell { { control.cancel_logout(serial); } - Task::batch( - modal - .surfaces - .into_iter() - .map(|surface| destroy_layer_surface(surface.id)), - ) + let mut tasks = modal + .surfaces + .into_iter() + .map(|surface| destroy_layer_surface(surface.id)) + .collect::>(); + if cancel && let Some(approval) = modal.inhibitors { + tasks.push(Self::cancel_query(approval)); + } + Task::batch(tasks) } pub(super) fn cancel_logout_modal(&mut self, serial: u32) -> Task { @@ -283,16 +343,22 @@ impl FereseShell { let Some(modal) = &mut self.system_modal else { return Task::none(); }; - if modal.motion.closing() || self.pending_power.is_some() { + if modal.motion.closing() || self.pending_power.is_some() || modal.inhibitors.is_none() { return Task::none(); } let Content::Power(power) = modal.content else { return self.close_system_modal(); }; + let approval = modal.inhibitors.as_ref().unwrap().clone(); let action = match power { PowerAction::Logout(serial) => { if let Some(control) = &self.control { - control.confirm_logout(serial); + control.confirm_logout( + serial, + approval.revision, + approval.token, + approval.force(), + ); return self.destroy_system_modal(false); } modal.error = Some("Ferese shell control is unavailable".to_owned()); @@ -307,10 +373,18 @@ impl FereseShell { let destroy = self.destroy_system_modal(false); let execute = Task::perform( async move { - tokio::task::spawn_blocking(move || status::execute_power(action)) - .await - .map_err(|error| error.to_string()) - .and_then(|result| result) + tokio::task::spawn_blocking(move || { + let result = approval + .prepare() + .and_then(|()| status::execute_power(action, approval.force())); + if result.is_err() { + approval.cancel(); + } + result + }) + .await + .map_err(|error| error.to_string()) + .and_then(|result| result) }, move |result| cosmic::Action::App(Message::PowerCompleted(id, result)), ); @@ -369,6 +443,33 @@ impl FereseShell { .class(theme::Text::Color(palette.muted)), ] .spacing(12); + if let Some(inhibitors) = &modal.inhibitors + && !inhibitors.reasons.is_empty() + { + rows = rows.push( + text("Applications requested that this action be prevented:") + .font(shell_font()) + .size(13) + .class(theme::Text::Color(palette.muted)), + ); + let items = inhibitors.reasons.iter().fold( + column::with_capacity(inhibitors.reasons.len()).spacing(6), + |rows, reason| rows.push(text(reason).font(shell_font()).size(13)), + ); + rows = rows.push( + container(cosmic::widget::scrollable(items).height(Length::Shrink)) + .max_height(160), + ); + } + let label = if modal + .inhibitors + .as_ref() + .is_some_and(|approval| approval.force()) + { + format!("{} anyway", action.label()) + } else { + action.label().to_owned() + }; if let Some(error) = &modal.error { rows = rows.push(text(error).size(13)); } @@ -378,9 +479,14 @@ impl FereseShell { button::text("Cancel") .class(ferese_theme::controls::button_style(palette, false)) .on_press(cosmic::Action::App(Message::CancelPower)), - button::text(action.label()) + button::text(label) .class(ferese_theme::controls::button_style(palette, true)) - .on_press(cosmic::Action::App(Message::ExecutePower)), + .on_press_maybe( + modal + .inhibitors + .is_some() + .then_some(cosmic::Action::App(Message::ExecutePower)) + ), ] .spacing(10), )