Implement native inhibition and acknowledged session-ending confirmation

This commit is contained in:
Samuel Onoja committed 2026-09-30 02:38:54 +01:00
1 parent cc441c049e
commit 29a71e9974
27 files changed
+1915 -142

No files matched your search

+1 -2
View File
@@ -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)
{
+1 -2
View File
@@ -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();
}
}
+137 -4
View File
@@ -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<u32, CommandError> {
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<Value, CommandError> {
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()))?;
+1
View File
@@ -13,6 +13,7 @@ mod input;
mod ipc;
mod metrics;
mod overview;
mod portal_session;
mod portal_shortcuts;
mod presentation;
mod private_client;
+422
View File
@@ -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<u64>,
deadline: Instant,
pending: HashSet<u64>,
}
pub(crate) struct PortalSession {
inhibitors: HashMap<u64, Inhibition>,
monitors: HashSet<u64>,
waiters: HashMap<u64, (u64, SyncSender<Response>)>,
phase: u32,
locked: bool,
revision: u64,
inhibitor_revision: u32,
next_query: u32,
query: Option<Query>,
transitions: VecDeque<Value>,
}
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<Response>,
) {
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::<Vec<_>>();
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());
}
}
+2
View File
@@ -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");
+74 -8
View File
@@ -14,7 +14,7 @@ use smithay::{
use crate::{Ferese, private_client::ClientCapabilities, state::ClientState};
pub(crate) fn init_global(display: &DisplayHandle) {
display.create_global::<Ferese, FereseShellManagerV1, _>(3, ());
display.create_global::<Ferese, FereseShellManagerV1, _>(4, ());
}
pub(crate) fn config_chunks(source: &str) -> Vec<&str> {
@@ -137,11 +137,25 @@ impl Dispatch<FereseShellV1, ()> 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<FereseShellV1, ()> 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<u32>,
query: Option<u32>,
serial: u32,
token: u32,
) -> bool {
pending == Some(serial) && query == Some(token)
}
fn consume_logout_confirmation(pending: &mut Option<u32>, serial: u32) -> bool {
if *pending == Some(serial) {
*pending = None;
@@ -199,6 +221,34 @@ fn consume_logout_confirmation(pending: &mut Option<u32>, 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);
+6
View File
@@ -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<Window>,
pub workspaces: WorkspaceSet,
pub output_workspaces: OutputWorkspaceMap,
@@ -259,6 +261,7 @@ pub struct Ferese {
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) logout_query: Option<u32>,
pub(crate) logout_owner: Option<smithay::reexports::wayland_server::backend::ObjectId>,
pub(crate) blur_programs: HashMap<ErasedContextId, crate::winit::BlurProgram>,
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,