From 8ad52931788dd527c6f3c17b50a68e4e4366365d Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 2 Oct 2026 08:48:01 +0100 Subject: [PATCH] Model lock ownership explicitly and allow protected crash recovery --- compositor/src/backends/direct.rs | 10 +- compositor/src/handlers/activation.rs | 2 +- compositor/src/handlers/compositor.rs | 2 +- compositor/src/handlers/layer_shell.rs | 2 +- compositor/src/handlers/screencopy.rs | 2 +- compositor/src/handlers/window_capture.rs | 6 +- compositor/src/handlers/xdg_shell.rs | 6 +- compositor/src/input.rs | 20 +- compositor/src/input_capture.rs | 4 +- compositor/src/ipc.rs | 10 +- compositor/src/main.rs | 2 +- compositor/src/presentation_policy.rs | 2 +- compositor/src/render/scene.rs | 2 +- compositor/src/session_lock.rs | 239 +++++++++++++++++++--- compositor/src/shell_control.rs | 6 +- compositor/src/state/focus.rs | 6 +- compositor/src/state/hit_testing.rs | 4 +- compositor/src/state/layout.rs | 2 +- compositor/src/state/navigation.rs | 4 +- compositor/src/state/outputs.rs | 4 +- compositor/src/winit.rs | 4 +- scripts/tests/test_lock_isolated.py | 5 + 22 files changed, 264 insertions(+), 80 deletions(-) diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 7701221..0188a81 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -702,7 +702,7 @@ fn open_primary_device( } pub(crate) fn sleep_locked_outputs(state: &mut Ferese) { - if !state.session_lock.active || !state.session_lock.sleeping { + if !state.session_lock.active() || !state.session_lock.sleeping { return; } @@ -972,7 +972,7 @@ fn render_output( crtc: crtc::Handle, plan: crate::frame_scheduler::FramePlan, ) -> bool { - if state.session_lock.active && state.session_lock.sleeping { + if state.session_lock.active() && state.session_lock.sleeping { sleep_locked_outputs(state); let asleep = state .direct_backend @@ -1025,7 +1025,7 @@ fn render_output( let flags = planes::frame_flags( state.output_has_fullscreen_for_frame(&output.output, frame.overview.is_presenting()), frame.animating, - state.session_lock.active, + state.session_lock.active(), ); let result = output @@ -1096,7 +1096,7 @@ fn render_output( .render_metrics .record_frame(render_started.elapsed(), &damage, missed_deadlines, effects); output.frame_pending = true; - output.lock_frame_pending = state.session_lock.active; + output.lock_frame_pending = state.session_lock.active(); Ok(true) })() }; @@ -1633,7 +1633,7 @@ fn deliver_frame_callbacks(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle fn send_frame_callbacks(state: &mut Ferese, output: &Output) { let eligible = state.callback_outputs(); - if state.session_lock.active { + if state.session_lock.active() { state.lock_frame_callbacks(output); return; } diff --git a/compositor/src/handlers/activation.rs b/compositor/src/handlers/activation.rs index 81d00cb..9b7605b 100644 --- a/compositor/src/handlers/activation.rs +++ b/compositor/src/handlers/activation.rs @@ -20,7 +20,7 @@ impl XdgActivationHandler for Ferese { } fn request_activation(&mut self, token: XdgActivationToken, data: XdgActivationTokenData, surface: WlSurface) { - if self.session_lock.active || self.input_capture.captures(1) { + if self.session_lock.active() || self.input_capture.captures(1) { return; } let valid = data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE; diff --git a/compositor/src/handlers/compositor.rs b/compositor/src/handlers/compositor.rs index fe50e41..1dc3198 100644 --- a/compositor/src/handlers/compositor.rs +++ b/compositor/src/handlers/compositor.rs @@ -79,7 +79,7 @@ impl CompositorHandler for Ferese { } fn destroyed(&mut self, surface: &WlSurface) { - if self.session_lock.active { + if self.session_lock.active() { self.session_lock .surfaces .retain(|_, lock| lock.wl_surface() != surface); diff --git a/compositor/src/handlers/layer_shell.rs b/compositor/src/handlers/layer_shell.rs index b5ec0f5..28ad855 100644 --- a/compositor/src/handlers/layer_shell.rs +++ b/compositor/src/handlers/layer_shell.rs @@ -141,7 +141,7 @@ pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) { layer.layer_surface().send_pending_configure(); let policy = layer.cached_state(); - if !state.session_lock.active + if !state.session_lock.active() && !state.input_capture.captures(1) && policy.keyboard_interactivity == KeyboardInteractivity::Exclusive && matches!(policy.layer, Layer::Top | Layer::Overlay) diff --git a/compositor/src/handlers/screencopy.rs b/compositor/src/handlers/screencopy.rs index 6067f87..9330f3d 100644 --- a/compositor/src/handlers/screencopy.rs +++ b/compositor/src/handlers/screencopy.rs @@ -254,7 +254,7 @@ impl Ferese { return; } let mut used = data.used.lock().unwrap(); - if self.session_lock.active { + if self.session_lock.active() { frame.failed(); return; } diff --git a/compositor/src/handlers/window_capture.rs b/compositor/src/handlers/window_capture.rs index 67885ce..e332a62 100644 --- a/compositor/src/handlers/window_capture.rs +++ b/compositor/src/handlers/window_capture.rs @@ -157,7 +157,7 @@ fn finish_conversion(state: &Ferese, result: Conversion) { if !frame.is_alive() || *data.used.lock().unwrap() { return; } - if state.session_lock.active + if state.session_lock.active() || !super::screencopy::capture_allowed() || !state.windows.ids().values().any(|id| *id == snapshot.id) { @@ -263,7 +263,7 @@ impl Ferese { budget: &Arc>, busy: &mut bool, ) -> Option { - if self.session_lock.active || !super::screencopy::capture_allowed() { + if self.session_lock.active() || !super::screencopy::capture_allowed() { return None; } let window = self @@ -363,7 +363,7 @@ pub(super) fn copy_snapshot(state: &Ferese, resource: &ZwlrScreencopyFrameV1, da resource.failed(); return; }; - if state.session_lock.active + if state.session_lock.active() || !super::screencopy::capture_allowed() || !state.windows.ids().values().any(|id| *id == snapshot.id) { diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index 87f7883..c9d6ef6 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -112,7 +112,7 @@ impl XdgShellHandler for Ferese { } fn move_request(&mut self, surface: ToplevelSurface, seat: wl_seat::WlSeat, serial: Serial) { - if self.session_lock.active { + if self.session_lock.active() { return; } let Some(seat) = Seat::from_resource(&seat) else { @@ -165,7 +165,7 @@ impl XdgShellHandler for Ferese { serial: Serial, edges: smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel::ResizeEdge, ) { - if self.session_lock.active { + if self.session_lock.active() { return; } let Some(seat) = Seat::from_resource(&seat) else { @@ -240,7 +240,7 @@ impl XdgShellHandler for Ferese { } fn grab(&mut self, surface: PopupSurface, seat: wl_seat::WlSeat, serial: Serial) { - if self.session_lock.active || self.input_capture.active() { + if self.session_lock.active() || self.input_capture.active() { return; } let Some(seat) = Seat::from_resource(&seat) else { diff --git a/compositor/src/input.rs b/compositor/src/input.rs index eb47633..9089414 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -70,7 +70,7 @@ impl Ferese { let Some(hint) = self.locked_pointer_hint.take() else { return; }; - if !hint.surface.is_alive() || self.session_lock.active || self.input_capture.active() { + if !hint.surface.is_alive() || self.session_lock.active() || self.input_capture.active() { return; } let Some(pointer) = self.seat.get_pointer() else { @@ -120,7 +120,7 @@ impl Ferese { self.lock_input_activity(); } - if self.session_lock.active { + if self.session_lock.active() { // An already-bound IME or drag client may install a grab after the // lock request. Never let that grab receive subsequent lock input. if let Some(keyboard) = seat.get_keyboard() { @@ -276,7 +276,7 @@ impl Ferese { event.time_msec(), |data, modifiers, keysym| { if data.focus_cycle.is_some() { - if data.session_lock.active || data.input_capture.captures(1) { + if data.session_lock.active() || data.input_capture.captures(1) { data.cancel_focus_cycle(); } else if !modifiers.alt { data.finish_focus_cycle(); @@ -288,7 +288,7 @@ impl Ferese { return FilterResult::Intercept(()); } } - if data.session_lock.active { + if data.session_lock.active() { return FilterResult::Forward; } let symbol = keysym.modified_sym().raw(); @@ -695,7 +695,7 @@ impl Ferese { } fn start_floating_pointer_grab(&mut self, pointer: &PointerHandle, button: u32, serial: Serial) -> bool { - if self.session_lock.active + if self.session_lock.active() || pointer.is_grabbed() || !matches!(button, BTN_LEFT | BTN_RIGHT) || !self @@ -767,7 +767,7 @@ impl Ferese { } fn swipe_navigation_blocked(&self) -> bool { - self.session_lock.active + self.session_lock.active() || self.active_shortcuts_inhibitor.is_some() || self.seat.get_pointer().is_some_and(|pointer| pointer.is_grabbed()) || self.seat.get_keyboard().is_some_and(|keyboard| keyboard.is_grabbed()) @@ -801,7 +801,7 @@ impl Ferese { if self.input_capture.captures(1) { return; } - if self.session_lock.active { + if self.session_lock.active() { self.focus_output_at(position); self.focus_lock_surface(); return; @@ -908,7 +908,7 @@ impl Ferese { } fn focus_window_under_pointer(&mut self, pointer: &PointerHandle, position: Point) { - if self.session_lock.active { + if self.session_lock.active() { return; } if self.overview.is_active() { @@ -963,7 +963,7 @@ impl Ferese { pointer: &PointerHandle, requested: Point, ) -> Point { - if self.session_lock.active { + if self.session_lock.active() { return requested; } let current = pointer.current_location(); @@ -1003,7 +1003,7 @@ impl Ferese { } pub fn activate_focused_pointer_constraint(&self, pointer: &PointerHandle) { - if self.session_lock.active { + if self.session_lock.active() { return; } let position = pointer.current_location(); diff --git a/compositor/src/input_capture.rs b/compositor/src/input_capture.rs index 8d7be6a..726ac95 100644 --- a/compositor/src/input_capture.rs +++ b/compositor/src/input_capture.rs @@ -449,7 +449,7 @@ impl crate::Ferese { pub(crate) fn restore_input_capture_focus(&mut self) { self.input_capture.restore_focus = false; self.cursor_redraw_pending = true; - if !self.session_lock.active { + if !self.session_lock.active() { self.restore_keyboard_focus(); if let Some(pointer) = self.seat.get_pointer() { let location = pointer.current_location(); @@ -475,7 +475,7 @@ impl crate::Ferese { ) -> bool { let pointer = self.seat.get_pointer().expect("seat has pointer"); let active = self.input_capture.active(); - let allowed = !self.session_lock.active + let allowed = !self.session_lock.active() && !self.overview.is_presenting() && !pointer.is_grabbed() && !self.swipe.active() diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index e48efd3..7955ea9 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -431,7 +431,7 @@ fn discard_undelivered_screenshot(response: &Response) { impl Ferese { fn handle_ipc_request(&mut self, owner: u64, request: Request) -> Response { - if self.session_lock.active + if self.session_lock.active() && !matches!( request.command.as_str(), "theme-get" @@ -544,10 +544,10 @@ impl Ferese { return Ok(json!({})); } "portal-shortcuts-poll" => { - return Ok(self.portal_shortcuts.poll(owner, self.session_lock.active)); + return Ok(self.portal_shortcuts.poll(owner, self.session_lock.active())); } "input-capture-register" => { - if self.session_lock.active { + if self.session_lock.active() { return Err(CommandError::new("session_locked", "Session is locked")); } let id = self @@ -716,7 +716,7 @@ impl Ferese { let _ = response.try_send(Response::error(request.id, $code, $message)); }; } - if self.session_lock.active { + if self.session_lock.active() { reject!("session_locked", "IPC unavailable while session is locked"); return; } @@ -813,7 +813,7 @@ impl Ferese { let reject = |message: String| { let _ = response.try_send(Response::error(request.id, "window_capture_failed", message)); }; - if self.session_lock.active || !screencopy::capture_allowed() { + if self.session_lock.active() || !screencopy::capture_allowed() { reject("Screen capture is unavailable".into()); return; } diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 27900a9..dc4bf2a 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -106,7 +106,7 @@ fn main() -> Result<(), Box> { // when no output has a pending frame. let wallpaper_changed = state.wallpaper.poll(); // All input/Wayland callbacks have returned, releasing seat locks. - if state.focus_cycle.is_some() && (state.session_lock.active || state.input_capture.captures(1)) { + if state.focus_cycle.is_some() && (state.session_lock.active() || state.input_capture.captures(1)) { state.cancel_focus_cycle(); } // Coalesce cursor changes and redraw here, never inside cursor_image. diff --git a/compositor/src/presentation_policy.rs b/compositor/src/presentation_policy.rs index 6d2b13c..6fa75f5 100644 --- a/compositor/src/presentation_policy.rs +++ b/compositor/src/presentation_policy.rs @@ -27,7 +27,7 @@ fn mapped_tree(surface: &WlSurface) -> bool { impl Ferese { pub(crate) fn callback_outputs(&self) -> HashMap { let mut surfaces = Vec::new(); - if self.session_lock.active { + if self.session_lock.active() { for lock in self.session_lock.surfaces.values() { smithay::desktop::utils::with_surfaces_surface_tree(lock.wl_surface(), |surface, _| { surfaces.push(surface.clone()); diff --git a/compositor/src/render/scene.rs b/compositor/src/render/scene.rs index a34e472..140c281 100644 --- a/compositor/src/render/scene.rs +++ b/compositor/src/render/scene.rs @@ -32,7 +32,7 @@ pub(crate) fn sampled_output_elements( include_cursor: bool, frame: &crate::state::FrameScene, ) -> Vec { - if state.session_lock.active { + if state.session_lock.active() { let Some(geometry) = state.space.output_geometry(output) else { return Vec::new(); }; diff --git a/compositor/src/session_lock.rs b/compositor/src/session_lock.rs index 5275218..702f98a 100644 --- a/compositor/src/session_lock.rs +++ b/compositor/src/session_lock.rs @@ -78,15 +78,35 @@ impl IdleSettings { } } +#[derive(Default)] +enum Lifecycle { + #[default] + Unlocked, + Acquiring(SessionLocker), + Locked(ExtSessionLockV1), + Orphaned, +} + +impl Lifecycle { + fn owner(&self) -> Option<&ExtSessionLockV1> { + match self { + Self::Acquiring(confirmation) => Some(confirmation.ext_session_lock()), + Self::Locked(owner) => Some(owner), + Self::Unlocked | Self::Orphaned => None, + } + } + + fn can_acquire(&self) -> bool { + self.owner().is_none_or(|owner| !owner.is_alive()) + } +} + #[derive(Default)] pub(crate) struct Lock { - pub active: bool, - owner: Option, - confirmation: Option, + lifecycle: Lifecycle, pub surfaces: HashMap, pub backgrounds: HashMap, presented: HashSet, - confirmed: bool, idle_since: Option, pub(crate) idle_opacity: f32, pub(crate) sleeping: bool, @@ -95,8 +115,12 @@ pub(crate) struct Lock { } impl Lock { + pub(crate) fn active(&self) -> bool { + !matches!(self.lifecycle, Lifecycle::Unlocked) + } + fn ready_for_idle<'a>(&self, outputs: impl Iterator) -> bool { - self.active && outputs.into_iter().all(|output| self.presented.contains(output)) + self.active() && outputs.into_iter().all(|output| self.presented.contains(output)) } pub(crate) fn output_added(&mut self, output: &Output) { @@ -109,7 +133,7 @@ impl Lock { } fn activity(&mut self, now: Instant) -> bool { - if !self.active { + if !self.active() { return false; } let changed = self.idle_opacity != 0.0 || self.sleeping; @@ -220,26 +244,39 @@ impl Ferese { } pub(crate) fn lock_frame_presented(&mut self, output: &Output) { - if !self.session_lock.active { + if !self.session_lock.active() { return; } let newly_presented = self.session_lock.presented.insert(output.clone()); - if self - .space - .outputs() - .all(|output| self.session_lock.presented.contains(output)) - && let Some(confirmation) = self.session_lock.confirmation.take() - && confirmation.ext_session_lock().is_alive() - { - confirmation.lock(); - self.session_lock.confirmed = true; - tracing::info!("session lock confirmed after safe output frames"); - } + self.confirm_lock_if_ready(); if newly_presented { self.refresh_lock_idle_policy(); } } + pub(crate) fn confirm_lock_if_ready(&mut self) { + // With no outputs there is no visible content to protect. Re-evaluate + // here after acquisition and output removal as well as presentation. + if !self.session_lock.ready_for_idle(self.space.outputs()) { + return; + } + + if matches!(self.session_lock.lifecycle, Lifecycle::Acquiring(_)) { + let Lifecycle::Acquiring(confirmation) = + std::mem::replace(&mut self.session_lock.lifecycle, Lifecycle::Orphaned) + else { + unreachable!() + }; + + let owner = confirmation.ext_session_lock().clone(); + if owner.is_alive() { + confirmation.lock(); + self.session_lock.lifecycle = Lifecycle::Locked(owner); + tracing::info!("session lock confirmed after safe output frames"); + } + } + } + pub(crate) fn configure_lock_surfaces(&mut self) { for (output, surface) in &self.session_lock.surfaces { crate::handlers::set_surface_tree_output(surface.wl_surface(), output); @@ -272,17 +309,19 @@ impl SessionLockHandler for Ferese { } fn lock(&mut self, confirmation: SessionLocker) { - if self.session_lock.active { + if !self.session_lock.lifecycle.can_acquire() { return; } + + // Replacing an orphan never passes through Unlocked. Existing protected + // presentation facts remain valid, but old surfaces must not own input. + self.session_lock.lifecycle = Lifecycle::Acquiring(confirmation); + self.session_lock.surfaces.clear(); self.cancel_logout_confirmation(); - self.session_lock.active = true; self.refresh_idle_inhibition(); self.session_lock.idle_since = Some(Instant::now()); self.input_capture.disable_all(); self.portal_session.set_locked(true); - self.session_lock.owner = Some(confirmation.ext_session_lock().clone()); - self.session_lock.confirmation = Some(confirmation); for capture in self.pending_screencopies.drain(..) { capture.fail(); } @@ -315,6 +354,8 @@ impl SessionLockHandler for Ferese { keyboard.unset_grab(self); keyboard.set_focus(self, None, serial); self.intercepted_keys.clear(); + self.lock_input_activity(); + self.confirm_lock_if_ready(); crate::backends::direct::render_all(self); } @@ -359,9 +400,9 @@ impl Dispatch for Ferese { display: &DisplayHandle, data_init: &mut DataInit<'_, Self>, ) { - let owner = state.session_lock.owner.as_ref() == Some(lock); + let owner = state.session_lock.lifecycle.owner() == Some(lock); if matches!(request, ext_session_lock_v1::Request::UnlockAndDestroy) - && (!owner || !state.session_lock.confirmed) + && (!owner || !matches!(state.session_lock.lifecycle, Lifecycle::Locked(_))) { lock.post_error( ext_session_lock_v1::Error::InvalidUnlock, @@ -377,6 +418,20 @@ impl Dispatch for Ferese { state, client, lock, request, data, display, data_init, ); } + + fn destroyed( + state: &mut Self, + _: smithay::reexports::wayland_server::backend::ClientId, + lock: &ExtSessionLockV1, + _: &SessionLockState, + ) { + if state.session_lock.lifecycle.owner() == Some(lock) { + state.session_lock.lifecycle = Lifecycle::Orphaned; + state.session_lock.surfaces.clear(); + state.focus_lock_surface(); + crate::backends::direct::render_all(state); + } + } } smithay::reexports::wayland_server::delegate_global_dispatch!(Ferese: [ExtSessionLockManagerV1: smithay::wayland::session_lock::SessionLockManagerGlobalData] => SessionLockManagerState); smithay::reexports::wayland_server::delegate_dispatch!(Ferese: [ExtSessionLockManagerV1: ()] => SessionLockManagerState); @@ -386,6 +441,129 @@ smithay::reexports::wayland_server::delegate_dispatch!(Ferese: [ExtSessionLockSu mod tests { use super::*; + #[test] + #[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"] + fn zero_outputs_confirm_and_dead_owner_can_be_replaced_without_unlocking() { + use std::io::{Read, Write}; + use std::os::unix::net::UnixStream; + use std::sync::Arc; + + use smithay::reexports::calloop::EventLoop; + use smithay::reexports::wayland_server::Display; + + let runtime = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").unwrap()); + assert!(runtime.starts_with(std::env::temp_dir())); + let mut event_loop = EventLoop::try_new().unwrap(); + let config = crate::config::Config::default().runtime_config().unwrap(); + let mut state = Ferese::new(&mut event_loop, Display::new().unwrap(), config).unwrap(); + + let acquire = |state: &mut Ferese| { + let (server, mut wire) = UnixStream::pair().unwrap(); + let client = state + .display_handle + .insert_client(server, Arc::new(crate::state::ClientState::default())) + .unwrap(); + let manager = client + .create_resource::(&state.display_handle, 1, ()) + .unwrap(); + // ext_session_lock_manager_v1.lock(new_id=2). + for word in [manager.id().protocol_id(), (12u32 << 16) | 1, 2] { + wire.write_all(&word.to_ne_bytes()).unwrap(); + } + wire + }; + let dispatch = |event_loop: &mut EventLoop<'static, Ferese>, state: &mut Ferese| { + event_loop.dispatch(Duration::from_millis(20), state).unwrap(); + state.display_handle.flush_clients().unwrap(); + }; + + let mut first = acquire(&mut state); + dispatch(&mut event_loop, &mut state); + assert!( + matches!(state.session_lock.lifecycle, Lifecycle::Locked(_)), + "no outputs must confirm immediately" + ); + first.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); + let mut event = [0u8; 8]; + first.read_exact(&mut event).unwrap(); + assert_eq!(u32::from_ne_bytes(event[..4].try_into().unwrap()), 2); + assert_eq!( + u32::from_ne_bytes(event[4..].try_into().unwrap()), + 8u32 << 16, + "protocol locked event" + ); + + let owner = state.session_lock.lifecycle.owner().cloned(); + + let mut competitor = acquire(&mut state); + dispatch(&mut event_loop, &mut state); + assert_eq!( + state.session_lock.lifecycle.owner(), + owner.as_ref(), + "a live owner cannot be replaced" + ); + // A rejected client must not unlock the confirmed owner. + for word in [2u32, (8u32 << 16) | 2] { + competitor.write_all(&word.to_ne_bytes()).unwrap(); + } + dispatch(&mut event_loop, &mut state); + assert_eq!(state.session_lock.lifecycle.owner(), owner.as_ref()); + + drop(competitor); + drop(first); + dispatch(&mut event_loop, &mut state); + assert!(matches!(state.session_lock.lifecycle, Lifecycle::Orphaned)); + assert!(state.session_lock.active()); + + let replacement = acquire(&mut state); + dispatch(&mut event_loop, &mut state); + assert!(matches!(state.session_lock.lifecycle, Lifecycle::Locked(_))); + assert_ne!(state.session_lock.lifecycle.owner(), owner.as_ref()); + drop(replacement); + dispatch(&mut event_loop, &mut state); + assert!(state.session_lock.active()); + + let output = Output::new( + "last-output".into(), + smithay::output::PhysicalProperties { + size: (0, 0).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + output.change_current_state( + Some(smithay::output::Mode { + size: (800, 600).into(), + refresh: 60_000, + }), + None, + None, + None, + ); + state.space.map_output(&output, (0, 0)); + state.register_output(&output, "last-output".into()); + + let pending = acquire(&mut state); + dispatch(&mut event_loop, &mut state); + assert!( + matches!(state.session_lock.lifecycle, Lifecycle::Acquiring(_)), + "a newly added output requires a protected frame" + ); + drop(pending); + dispatch(&mut event_loop, &mut state); + assert!(matches!(state.session_lock.lifecycle, Lifecycle::Orphaned)); + + let _replacement = acquire(&mut state); + dispatch(&mut event_loop, &mut state); + assert!(matches!(state.session_lock.lifecycle, Lifecycle::Acquiring(_))); + state.unregister_output(&output); + assert!( + matches!(state.session_lock.lifecycle, Lifecycle::Locked(_)), + "removing the last pending output must confirm" + ); + } + #[test] fn dim_fade_respects_speed_without_changing_idle_deadlines() { let policy = IdleSettings::default(); @@ -463,30 +641,29 @@ mod tests { }, ); let mut lock = Lock { - active: true, + lifecycle: Lifecycle::Orphaned, ..Lock::default() }; assert!(!lock.ready_for_idle(std::iter::once(&output))); lock.presented.insert(output.clone()); assert!(lock.ready_for_idle(std::iter::once(&output))); - assert!(!lock.confirmed); + assert!(matches!(lock.lifecycle, Lifecycle::Orphaned)); lock.output_added(&output); assert!(!lock.ready_for_idle(std::iter::once(&output))); - assert!(!lock.confirmed); + assert!(matches!(lock.lifecycle, Lifecycle::Orphaned)); } #[test] - fn input_wakes_a_sleeping_lock_without_unlocking_or_losing_confirmation() { + fn input_wakes_a_sleeping_orphan_without_unlocking() { let mut lock = Lock { - active: true, - confirmed: true, + lifecycle: Lifecycle::Orphaned, sleeping: true, idle_opacity: 1.0, ..Lock::default() }; let now = Instant::now(); assert!(lock.activity(now)); - assert!(lock.active && lock.confirmed); + assert!(lock.active() && matches!(lock.lifecycle, Lifecycle::Orphaned)); assert!(!lock.sleeping); assert_eq!(lock.idle_opacity, 0.0); assert_eq!(lock.idle_since, Some(now)); diff --git a/compositor/src/shell_control.rs b/compositor/src/shell_control.rs index 4b48dff..5fa1ece 100644 --- a/compositor/src/shell_control.rs +++ b/compositor/src/shell_control.rs @@ -94,7 +94,7 @@ impl Dispatch for Ferese { _display: &DisplayHandle, _data_init: &mut DataInit<'_, Self>, ) { - if state.session_lock.active { + if state.session_lock.active() { return; } match request { @@ -207,7 +207,7 @@ impl Ferese { } pub(crate) fn toggle_keybinding_guide(&mut self) -> bool { - if self.session_lock.active { + if self.session_lock.active() { return false; } let Some(shell) = self @@ -225,7 +225,7 @@ impl Ferese { } pub(crate) fn request_logout_confirmation(&mut self) { - if self.session_lock.active { + if self.session_lock.active() { return; } let shells = self diff --git a/compositor/src/state/focus.rs b/compositor/src/state/focus.rs index 9d600dd..bd0beb4 100644 --- a/compositor/src/state/focus.rs +++ b/compositor/src/state/focus.rs @@ -28,7 +28,7 @@ impl Ferese { } pub(crate) fn focus_last_window(&mut self) { - if self.session_lock.active || self.input_capture.captures(1) { + if self.session_lock.active() || self.input_capture.captures(1) { return; } self.cancel_focus_cycle(); @@ -43,7 +43,7 @@ impl Ferese { } pub(crate) fn cycle_focus(&mut self, reverse: bool, preview: bool) { - if self.session_lock.active || self.input_capture.captures(1) { + if self.session_lock.active() || self.input_capture.captures(1) { return; } let available = self.focus_candidates(); @@ -85,7 +85,7 @@ impl Ferese { let Some(mut cycle) = self.focus_cycle.take() else { return; }; - if !self.session_lock.active + if !self.session_lock.active() && !self.input_capture.captures(1) && let Some(id) = cycle.reconcile(&self.focus_candidates()) { diff --git a/compositor/src/state/hit_testing.rs b/compositor/src/state/hit_testing.rs index 8c89479..321053d 100644 --- a/compositor/src/state/hit_testing.rs +++ b/compositor/src/state/hit_testing.rs @@ -2,7 +2,7 @@ use super::*; impl Ferese { pub fn surface_under(&self, position: Point) -> Option<(WlSurface, Point)> { - if self.session_lock.active { + if self.session_lock.active() { return self.lock_surface_under(position); } if self.input_capture.active() { @@ -18,7 +18,7 @@ impl Ferese { } pub fn layer_under(&self, position: Point) -> Option<(LayerSurface, WlSurface, Point)> { - if self.session_lock.active { + if self.session_lock.active() { return None; } if let Some(layer) = self.layer_surface_under(position, &[Layer::Overlay, Layer::Top]) { diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index 114a81f..8af3626 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -84,7 +84,7 @@ impl Ferese { self.refresh_input_capture_zones(); let visible_workspaces = self.visible_workspace_ids(); self.reconcile_workspaces(&visible_workspaces); - if self.session_lock.active { + if self.session_lock.active() { self.configure_lock_surfaces(); } // Consume idle time before setting new targets; it must not become a diff --git a/compositor/src/state/navigation.rs b/compositor/src/state/navigation.rs index 71fbc25..2056d7b 100644 --- a/compositor/src/state/navigation.rs +++ b/compositor/src/state/navigation.rs @@ -7,7 +7,7 @@ impl Ferese { pub(super) fn focus_swipe_is_current(&self, swipe: &FocusSwipe) -> bool { self.animations_enabled - && !self.session_lock.active + && !self.session_lock.active() && !self.overview.is_presenting() && self.workspaces.active_id() == swipe.workspace && self.focused_window == Some(swipe.from) @@ -552,7 +552,7 @@ impl Ferese { if self.input_capture.captures(1) { return; } - if self.session_lock.active { + if self.session_lock.active() { self.focus_lock_surface(); return; } diff --git a/compositor/src/state/outputs.rs b/compositor/src/state/outputs.rs index f8f7272..763bd4b 100644 --- a/compositor/src/state/outputs.rs +++ b/compositor/src/state/outputs.rs @@ -12,7 +12,7 @@ impl Ferese { self.reset_animation_clock(); } - if self.session_lock.active { + if self.session_lock.active() { self.session_lock.output_added(output); self.lock_input_activity(); } @@ -77,6 +77,8 @@ impl Ferese { self.session_lock.surfaces.remove(output); self.session_lock.backgrounds.remove(output); self.session_lock.output_removed(output); + self.confirm_lock_if_ready(); + self.refresh_lock_idle_policy(); match self.output_workspaces.disconnect(output_id) { Ok(Some(target)) => { diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 263f5d5..8818179 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -187,7 +187,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() state.display_presentation.presented(&output); state.refresh_idle_inhibition(); - if state.session_lock.active { + if state.session_lock.active() { state.lock_frame_presented(&output); } let elapsed = render_started.elapsed(); @@ -226,7 +226,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() fn send_nested_frame_callbacks(state: &mut Ferese, output: &Output) { let eligible = state.callback_outputs(); - if state.session_lock.active { + if state.session_lock.active() { state.lock_frame_callbacks(output); return; } diff --git a/scripts/tests/test_lock_isolated.py b/scripts/tests/test_lock_isolated.py index 0888251..30f287e 100644 --- a/scripts/tests/test_lock_isolated.py +++ b/scripts/tests/test_lock_isolated.py @@ -54,6 +54,11 @@ class NativeLockTest(unittest.TestCase): result=capture(root/'after-crash.png') assert result.returncode != 0, 'Capture unexpectedly succeeded after locker crash' print('Capture remains denied after locker crash',flush=True) + locker=subprocess.Popen(['bwrap','--bind','/','/','--dev-bind','/dev','/dev','--ro-bind',str(pam),'/etc/pam.d','--unshare-user','--',str(binary_dir / 'ferese-lock')],env=childenv,stdout=subprocess.DEVNULL,stderr=locker_log,start_new_session=True) + assert locker.wait(timeout=20) == 0, (root/'locker.log').read_text() + result=capture(root/'replacement.png') + assert result.returncode != 0, 'Capture unexpectedly succeeded after locker takeover' + print('Replacement locker confirmed; capture remains denied',flush=True) finally: if locker is not None: try: os.killpg(locker.pid, signal.SIGKILL)