// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! On-disk session state: `$XDG_STATE_HOME//{session.json,state.bin}`, on Windows `%LOCALAPPDATA%\\` when `XDG_STATE_HOME` is unset. //! //! `session.json` carries the compositor session id, a clean-exit marker //! and the pid of the run that wrote it; `state.bin` holds the bytes the //! application returned from `App::save_state`. Everything is best effort: //! I/O failures are logged and never abort the application. use std::fs; use std::io; use std::path::{ Path, PathBuf }; use std::time::Duration; use serde::{ Deserialize, Serialize }; use crate::app::App; /// How often a running app's state is saved, besides on a clean exit. pub( crate ) const SAVE_INTERVAL: Duration = Duration::from_secs( 30 ); /// Set to `1` by a shell that relaunches the app as part of a session restore. pub( crate ) const RESTORE_ENV: &str = "LTK_SESSION_RESTORE"; const SESSION_FILE: &str = "session.json"; const STATE_FILE: &str = "state.bin"; const FORMAT_VERSION: u32 = 1; /// Why this process is starting, as far as session restore is concerned. #[ derive( Clone, Copy, Debug, PartialEq, Eq ) ] pub( crate ) enum RestoreReason { /// Ordinary launch: fresh app state, compositor still restores geometry. Launch, /// The previous run of this app id did not exit cleanly. Recover, /// Relaunched by the shell as part of a session restore. SessionRestore, } /// Outcome of inspecting the state directory at startup. #[ derive( Clone, Copy, Debug, PartialEq, Eq ) ] pub( crate ) enum Startup { Reason( RestoreReason ), /// Another live instance owns the directory: run without persistence. Concurrent, } #[ derive( Serialize, Deserialize, Default, Debug, Clone ) ] pub( crate ) struct SessionFile { pub version: u32, pub session_id: Option, pub clean_exit: bool, pub pid: u32, } /// Handle on one application's state directory. pub( crate ) struct StateStore { dir: PathBuf, session_id: Option, last_saved: Option>, } /// The state directory for `app_id` under `base`, or `None` when the id is unusable as a path component or there is no base. pub( crate ) fn state_dir( app_id: &str, base: Option ) -> Option { if app_id.is_empty() || app_id.contains( '/' ) || app_id == "." || app_id == ".." { return None; } #[ cfg( windows ) ] if app_id.contains( [ '\\', ':' ] ) { return None; } Some( base?.join( app_id ) ) } impl StateStore { pub fn open( app_id: &str ) -> Option { let dir = state_dir( app_id, crate::dirs::state_home() ); if dir.is_none() { eprintln!( "ltk: session state disabled: cannot derive a state directory for app_id {app_id:?}" ); } Self::open_at( dir? ) } pub fn open_at( dir: PathBuf ) -> Option { let mut builder = fs::DirBuilder::new(); builder.recursive( true ); #[ cfg( unix ) ] { use std::os::unix::fs::DirBuilderExt; builder.mode( 0o700 ); } if let Err( e ) = builder.create( &dir ) { eprintln!( "ltk: session state disabled: cannot create {}: {e}", dir.display() ); return None; } let mut store = Self { dir, session_id: None, last_saved: None }; store.session_id = store.read_session_file().and_then( |f| f.session_id ); Some( store ) } pub fn read_session_file( &self ) -> Option { let bytes = fs::read( self.dir.join( SESSION_FILE ) ).ok()?; serde_json::from_slice::( &bytes ).ok().filter( |f| f.version == FORMAT_VERSION ) } pub fn decide( &self, env_restore: bool ) -> Startup { if env_restore { return Startup::Reason( RestoreReason::SessionRestore ); } match self.read_session_file() { Some( f ) if !f.clean_exit && f.pid != 0 && Self::pid_alive( f.pid ) => Startup::Concurrent, Some( f ) if !f.clean_exit => Startup::Reason( RestoreReason::Recover ), _ => Startup::Reason( RestoreReason::Launch ), } } pub fn session_id( &self ) -> Option { self.session_id.clone() } pub fn load_state( &self ) -> Option> { fs::read( self.dir.join( STATE_FILE ) ).ok().filter( |b| !b.is_empty() ) } pub fn mark_running( &mut self ) { self.write_session_file( false ); } pub fn set_session_id( &mut self, id: String ) { self.session_id = Some( id ); self.write_session_file( false ); } /// Persist `state` when it differs from the last saved bytes; `None` /// removes any stale state file. Returns whether the disk was touched. pub fn save_state_if_changed( &mut self, state: Option> ) -> bool { match state { Some( bytes ) => { if self.last_saved.as_deref() == Some( bytes.as_slice() ) { return false; } match Self::write_atomic( &self.dir.join( STATE_FILE ), &bytes ) { Ok( () ) => { self.last_saved = Some( bytes ); true } Err( e ) => { eprintln!( "ltk: session state: cannot write {STATE_FILE}: {e}" ); false } } } None => { let path = self.dir.join( STATE_FILE ); let existed = path.exists(); if existed { if let Err( e ) = fs::remove_file( &path ) { eprintln!( "ltk: session state: cannot remove {STATE_FILE}: {e}" ); } } self.last_saved = None; existed } } } pub fn mark_clean_exit( &mut self, state: Option> ) { self.save_state_if_changed( state ); self.write_session_file( true ); } fn write_session_file( &self, clean_exit: bool ) { let file = SessionFile { version: FORMAT_VERSION, session_id: self.session_id.clone(), clean_exit, pid: std::process::id(), }; match serde_json::to_vec( &file ) { Ok( bytes ) => { if let Err( e ) = Self::write_atomic( &self.dir.join( SESSION_FILE ), &bytes ) { eprintln!( "ltk: session state: cannot write {SESSION_FILE}: {e}" ); } } Err( e ) => eprintln!( "ltk: session state: cannot encode {SESSION_FILE}: {e}" ), } } fn write_atomic( path: &Path, bytes: &[u8] ) -> io::Result<()> { use std::io::Write; let mut tmp = path.as_os_str().to_owned(); tmp.push( ".tmp" ); let tmp = PathBuf::from( tmp ); { let mut options = fs::OpenOptions::new(); options.write( true ).create( true ).truncate( true ); #[ cfg( unix ) ] { use std::os::unix::fs::OpenOptionsExt; options.mode( 0o600 ); } let mut f = options.open( &tmp )?; f.write_all( bytes )?; f.sync_all()?; } fs::rename( &tmp, path ) } #[ cfg( unix ) ] fn pid_alive( pid: u32 ) -> bool { Path::new( "/proc" ).join( pid.to_string() ).exists() } #[ cfg( windows ) ] fn pid_alive( pid: u32 ) -> bool { use windows_sys::Win32::Foundation::{ CloseHandle, STILL_ACTIVE }; use windows_sys::Win32::System::Threading::{ GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION }; // SAFETY: the handle is checked for null and closed before returning; `code` is a place for the call to write. unsafe { let process = OpenProcess( PROCESS_QUERY_LIMITED_INFORMATION, 0, pid ); if process.is_null() { return false; } let mut code = 0; let alive = GetExitCodeProcess( process, &mut code ) != 0 && code == STILL_ACTIVE as u32; CloseHandle( process ); alive } } } /// Before any window exists: read the restore hint from the environment, decide why the app is starting, hand it its saved bytes when that calls for them, and mark the run as live. `None` for the store when persistence is off: an unusable `app_id`, no state directory, or another instance running. pub( crate ) fn bootstrap( app: &mut A ) -> ( Option, RestoreReason ) { 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 Some( mut store ) = StateStore::open( app.app_id() ) else { return ( None, RestoreReason::Launch ) }; let reason = match store.decide( env_restore ) { Startup::Concurrent => { eprintln!( "ltk: another instance of {} is running; session persistence disabled", app.app_id() ); return ( None, RestoreReason::Launch ); } Startup::Reason( reason ) => reason, }; if reason != RestoreReason::Launch || app.restore_on_launch() { if let Some( bytes ) = store.load_state() { app.restore_state( bytes ); } } store.mark_running(); ( Some( store ), reason ) } #[ cfg( test ) ] mod tests { use super::*; use std::sync::atomic::{ AtomicU32, Ordering }; static COUNTER: AtomicU32 = AtomicU32::new( 0 ); fn temp_dir() -> PathBuf { let n = COUNTER.fetch_add( 1, Ordering::Relaxed ); let dir = std::env::temp_dir().join( format!( "ltk-session-state-{}-{n}", std::process::id() ) ); let _ = fs::remove_dir_all( &dir ); dir } #[ test ] fn state_dir_is_the_id_under_the_base() { let base = || Some( PathBuf::from( "/home/u/.local/state" ) ); assert_eq!( state_dir( "net.example.App", base() ), Some( PathBuf::from( "/home/u/.local/state/net.example.App" ) ) ); assert_eq!( state_dir( "net.example.App", None ), None ); } #[ test ] fn state_dir_rejects_bad_ids() { let base = || Some( PathBuf::from( "/home/u/.local/state" ) ); assert_eq!( state_dir( "", base() ), None ); assert_eq!( state_dir( "a/b", base() ), None ); assert_eq!( state_dir( "..", base() ), None ); } #[ test ] fn fresh_dir_is_a_plain_launch() { let store = StateStore::open_at( temp_dir() ).unwrap(); assert_eq!( store.decide( false ), Startup::Reason( RestoreReason::Launch ) ); assert_eq!( store.load_state(), None ); assert_eq!( store.session_id(), None ); } #[ test ] fn mark_running_then_reopen_is_concurrent() { let dir = temp_dir(); let mut store = StateStore::open_at( dir.clone() ).unwrap(); store.mark_running(); let file = store.read_session_file().unwrap(); assert!( !file.clean_exit ); assert_eq!( file.pid, std::process::id() ); let second = StateStore::open_at( dir ).unwrap(); assert_eq!( second.decide( false ), Startup::Concurrent ); } #[ test ] fn dead_pid_means_recover_and_env_wins() { let dir = temp_dir(); let store = StateStore::open_at( dir.clone() ).unwrap(); let file = SessionFile { version: FORMAT_VERSION, session_id: Some( "abc".into() ), clean_exit: false, pid: 4_000_000_000 }; fs::write( dir.join( SESSION_FILE ), serde_json::to_vec( &file ).unwrap() ).unwrap(); assert_eq!( store.decide( false ), Startup::Reason( RestoreReason::Recover ) ); assert_eq!( store.decide( true ), Startup::Reason( RestoreReason::SessionRestore ) ); } #[ test ] fn save_state_if_changed_dedupes_and_removes() { let dir = temp_dir(); let mut store = StateStore::open_at( dir.clone() ).unwrap(); assert!( store.save_state_if_changed( Some( b"one".to_vec() ) ) ); assert_eq!( fs::read( dir.join( STATE_FILE ) ).unwrap(), b"one" ); assert!( !store.save_state_if_changed( Some( b"one".to_vec() ) ) ); assert!( store.save_state_if_changed( Some( b"two".to_vec() ) ) ); assert!( !dir.join( "state.bin.tmp" ).exists() ); assert!( store.save_state_if_changed( None ) ); assert!( !dir.join( STATE_FILE ).exists() ); assert!( !store.save_state_if_changed( None ) ); } #[ cfg( unix ) ] #[ test ] fn file_modes_are_private() { use std::os::unix::fs::PermissionsExt; let dir = temp_dir(); let mut store = StateStore::open_at( dir.clone() ).unwrap(); store.save_state_if_changed( Some( b"x".to_vec() ) ); assert_eq!( fs::metadata( &dir ).unwrap().permissions().mode() & 0o777, 0o700 ); assert_eq!( fs::metadata( dir.join( STATE_FILE ) ).unwrap().permissions().mode() & 0o777, 0o600 ); } #[ test ] fn session_id_round_trips_and_clean_exit_flips() { let dir = temp_dir(); let mut store = StateStore::open_at( dir.clone() ).unwrap(); store.mark_running(); store.set_session_id( "session-1".into() ); let reopened = StateStore::open_at( dir ).unwrap(); assert_eq!( reopened.session_id(), Some( "session-1".to_string() ) ); store.mark_clean_exit( Some( b"final".to_vec() ) ); let file = store.read_session_file().unwrap(); assert!( file.clean_exit ); assert_eq!( file.session_id.as_deref(), Some( "session-1" ) ); assert_eq!( store.load_state().unwrap(), b"final" ); } }