// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! `xdg-session-management-v1` client glue plus the runtime hooks that //! persist `App::save_state` and turn signals into a clean exit. use std::time::Duration; use calloop::timer::{ TimeoutAction, Timer }; use calloop::LoopHandle; use smithay_client_toolkit::reexports::client::globals::GlobalList; use smithay_client_toolkit::reexports::client::{ Connection, Dispatch, QueueHandle }; use smithay_client_toolkit::shell::xdg::window::Window; use crate::app::App; use crate::protocol::xdg_session_management_v1:: { xdg_session_manager_v1::{ self, XdgSessionManagerV1 }, xdg_session_v1::{ self, XdgSessionV1 }, xdg_toplevel_session_v1::{ self, XdgToplevelSessionV1 }, }; use crate::session_state::{ RestoreReason, Startup, StateStore }; use super::error::RunError; use super::AppData; pub( crate ) const TOPLEVEL_NAME: &str = "main"; pub( crate ) const SAVE_INTERVAL: Duration = Duration::from_secs( 30 ); pub( crate ) const RESTORE_ENV: &str = "LTK_SESSION_RESTORE"; pub( crate ) struct SessionRuntime { pub session: Option, pub toplevel_session: Option, /// `None` when persistence is off: layer / lock surfaces, an unusable /// app_id, a concurrent instance, or after `replaced`. pub store: Option, pub reason: RestoreReason, pub replaced: bool, } impl SessionRuntime { pub fn disabled() -> Self { Self { session: None, toplevel_session: None, store: None, reason: RestoreReason::Launch, replaced: false, } } /// Phase 1, before any window exists: read the restore hint from the /// environment, decide the reason, hand saved bytes to the app and mark /// the run as live. pub fn bootstrap( app: &mut A ) -> Self { let env_restore = std::env::var_os( RESTORE_ENV ).is_some_and( |v| v == "1" ); if std::env::var_os( RESTORE_ENV ).is_some() { // SAFETY: removing an env var is sound only when no other thread // is reading the environment concurrently. We are still in the // init phase before `set_channel_sender`, so the app has had // no opportunity to spawn worker threads yet. unsafe { std::env::remove_var( RESTORE_ENV ); } } let mut rt = Self::disabled(); let Some( mut store ) = StateStore::open( app.app_id() ) else { return rt }; match store.decide( env_restore ) { Startup::Concurrent => { eprintln!( "ltk: another instance of {} is running; session persistence disabled", app.app_id() ); return rt; } Startup::Reason( reason ) => rt.reason = reason, } if rt.reason != RestoreReason::Launch { if let Some( bytes ) = store.load_state() { app.restore_state( bytes ); } } store.mark_running(); rt.store = Some( store ); rt } /// Phase 2: bind the manager and open the session. Silent when the /// compositor lacks the global. The manager proxy is not kept: it has /// no state of its own and the session outlives it server-side. pub fn bind( &mut self, globals: &GlobalList, qh: &QueueHandle> ) { let Some( store ) = &self.store else { return }; let manager: Option = globals.bind( qh, 1..=1, () ).ok(); let Some( manager ) = manager else { return }; let reason = match self.reason { RestoreReason::Launch => xdg_session_manager_v1::Reason::Launch, RestoreReason::Recover => xdg_session_manager_v1::Reason::Recover, RestoreReason::SessionRestore => xdg_session_manager_v1::Reason::SessionRestore, }; self.session = Some( manager.get_session( reason, store.session_id(), qh, () ) ); } /// Phase 3: register the main toplevel. Must run before the window's /// first commit or the compositor raises `already_mapped`. pub fn attach_toplevel( &mut self, window: &Window, qh: &QueueHandle> ) { let Some( session ) = &self.session else { return }; self.toplevel_session = Some( session.restore_toplevel( window.xdg_toplevel(), TOPLEVEL_NAME.to_string(), qh, () ) ); } pub fn periodic_save( &mut self, app: &A ) { if self.replaced { return; } if let Some( store ) = &mut self.store { store.save_state_if_changed( app.save_state() ); } } pub fn on_exit( &mut self, app: &A ) { if self.replaced { return; } if let Some( store ) = &mut self.store { store.mark_clean_exit( app.save_state() ); } } pub fn on_replaced( &mut self ) { eprintln!( "ltk: session taken over by another instance; this one stops persisting state" ); if let Some( t ) = self.toplevel_session.take() { t.destroy(); } if let Some( s ) = self.session.take() { s.destroy(); } self.replaced = true; self.store = None; } } pub( crate ) fn install_signal_source( handle: &LoopHandle<'static, AppData> ) -> Result<(), RunError> { use calloop::signals::{ Signal, Signals }; let signals = Signals::new( &[ Signal::SIGTERM, Signal::SIGINT ] ) .map_err( |e| RunError::EventLoop( format!( "Signals::new: {e}" ) ) )?; handle .insert_source( signals, |event, _, data: &mut AppData| { eprintln!( "ltk: {:?} received, exiting cleanly", event.signal() ); data.exit_requested = true; } ) .map_err( |e| RunError::EventLoop( format!( "signals insert_source: {e:?}" ) ) )?; Ok( () ) } pub( crate ) fn install_save_timer( handle: &LoopHandle<'static, AppData> ) -> Result<(), RunError> { handle .insert_source( Timer::from_duration( SAVE_INTERVAL ), |_, _, data: &mut AppData| { data.session.periodic_save( &data.app ); TimeoutAction::ToDuration( SAVE_INTERVAL ) } ) .map_err( |e| RunError::EventLoop( format!( "save timer insert_source: {e:?}" ) ) )?; Ok( () ) } impl Dispatch for AppData { fn event( _state: &mut Self, _proxy: &XdgSessionManagerV1, _event: xdg_session_manager_v1::Event, _data: &(), _conn: &Connection, _qh: &QueueHandle, ) { } } impl Dispatch for AppData { fn event( state: &mut Self, _proxy: &XdgSessionV1, event: xdg_session_v1::Event, _data: &(), _conn: &Connection, _qh: &QueueHandle, ) { match event { xdg_session_v1::Event::Created { session_id } => { if let Some( store ) = &mut state.session.store { store.set_session_id( session_id ); } } xdg_session_v1::Event::Restored => {} xdg_session_v1::Event::Replaced => state.session.on_replaced(), } } } impl Dispatch for AppData { fn event( _state: &mut Self, _proxy: &XdgToplevelSessionV1, _event: xdg_toplevel_session_v1::Event, _data: &(), _conn: &Connection, _qh: &QueueHandle, ) { } }