Session management: xdg-session-management-v1 client, mandatory App::app_id / save_state / restore_state, runtime-managed state persistence and clean exit on signals (0.3.0)
Applications built on ltk had no way to come back where the user left them: the toolkit hardcoded `app_id = "ltk"` on every toplevel, never wrote anything to disk, and died on SIGTERM without a chance to save. This release gives the runtime the whole plumbing and asks each application only for the bytes worth keeping, in the spirit of Android's saved-instance state. The `App` trait gains three mandatory methods, deliberately without default bodies so every application states its position: `app_id()` (reverse-DNS, used for `xdg_toplevel.set_app_id`, the AccessKit application name and the state directory — the `app_id` element of the deprecated `window_config` tuple is now ignored and a one-time warning reports a mismatch), `save_state() -> Option<Vec<u8>>` and `restore_state(Vec<u8>)`. The bytes are opaque; the trait carries no serde bound. Their rustdoc is the contract: when the runtime saves, where the files live, when the bytes come back and when they do not, what must never go in them, and a worked serde_json example. The runtime persists under `$XDG_STATE_HOME/<app_id>/` (falling back to `~/.local/state`): `session.json` holds the compositor session id, a clean-exit marker and the writer's pid; `state.bin` holds the application bytes. Writes are atomic (temp file + rename, mode 0600, directory 0700) and best-effort. State is saved every 30 s when the bytes changed, once after the event loop exits (which covers `on_close_requested`, `requested_exit` and lost connections), and on SIGTERM/SIGINT — a calloop signal source, installed before any thread exists, now turns those into a clean exit of the loop instead of process death. `restore_state` runs synchronously in `try_run` before the window is created and before the first `view()`, and only when the process is relaunched as part of a session restore (`LTK_SESSION_RESTORE=1`, removed from the environment before the app can spawn children) or when the previous run left `clean_exit: false`; a plain launch starts fresh. A second concurrent instance detects the live pid and runs with persistence disabled rather than clobbering the first. The compositor side of geometry restore goes through `xdg-session-management-v1`. Neither wayland-protocols nor sctk ship generated code for it yet, so the XML is vendored under `protocols/` and `wayland-scanner` generates the client module in-tree (`src/protocol/`), resolving the crate names through sctk's reexports so the bindings stay on the crate instances sctk links. Before the first commit of a `ShellMode::Window` toplevel the runtime binds `xdg_session_manager_v1`, calls `get_session(reason, stored_id)` and `restore_toplevel(toplevel, "main")`; the three window-creation paths in `run.rs` are folded into one `make_window` helper so the attach always sits immediately before `commit()`. `created` persists the id, `replaced` destroys the objects and stops persisting. Compositors without the global lose only the geometry half. Layer-shell and session-lock surfaces skip the whole machinery. Every `App` implementor in the tree is updated: the twelve examples (`showcase`, `scroll` and `mini_shell` persist real state; the rest return `None`), both integration tests (`event_loop_flow` gains `save_restore_round_trip`), the in-source and markdown doctests, README, onboarding, cookbook (new recipe "Surviving relaunch: session state") and architecture docs, and the changelog. `src/session_state.rs` carries unit tests over a temporary state directory. `Makefile install` now copies `protocols/` into the cargo registry — without it downstream builds would fail inside the proc-macro — and `debian/copyright` covers the vendored XML. The trait change is breaking, hence 0.3.0. Also fixes the pre-existing `viewport_tests` module in `render/mod.rs`, which used `Length` without importing it and broke `cargo test`.
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! On-disk session state: `$XDG_STATE_HOME/<app_id>/{session.json,state.bin}`.
|
||||
//!
|
||||
//! `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::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{ Path, PathBuf };
|
||||
|
||||
use serde::{ Deserialize, Serialize };
|
||||
|
||||
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<String>,
|
||||
pub clean_exit: bool,
|
||||
pub pid: u32,
|
||||
}
|
||||
|
||||
/// Handle on one application's state directory.
|
||||
pub( crate ) struct StateStore
|
||||
{
|
||||
dir: PathBuf,
|
||||
session_id: Option<String>,
|
||||
last_saved: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Resolve the state directory for `app_id`, or `None` when the id is unusable
|
||||
/// as a path component or no base directory can be determined.
|
||||
pub( crate ) fn state_dir(
|
||||
app_id: &str,
|
||||
xdg_state_home: Option<&OsStr>,
|
||||
home: Option<&OsStr>,
|
||||
) -> Option<PathBuf>
|
||||
{
|
||||
if app_id.is_empty() || app_id.contains( '/' ) || app_id == "." || app_id == ".."
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let base = match xdg_state_home.filter( |v| !v.is_empty() )
|
||||
{
|
||||
Some( v ) => PathBuf::from( v ),
|
||||
None => PathBuf::from( home.filter( |v| !v.is_empty() )? ).join( ".local" ).join( "state" ),
|
||||
};
|
||||
Some( base.join( app_id ) )
|
||||
}
|
||||
|
||||
impl StateStore
|
||||
{
|
||||
pub fn open( app_id: &str ) -> Option<Self>
|
||||
{
|
||||
let xdg = std::env::var_os( "XDG_STATE_HOME" );
|
||||
let home = std::env::var_os( "HOME" );
|
||||
let dir = state_dir( app_id, xdg.as_deref(), home.as_deref() );
|
||||
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<Self>
|
||||
{
|
||||
let mut builder = fs::DirBuilder::new();
|
||||
builder.recursive( true );
|
||||
{
|
||||
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<SessionFile>
|
||||
{
|
||||
let bytes = fs::read( self.dir.join( SESSION_FILE ) ).ok()?;
|
||||
serde_json::from_slice::<SessionFile>( &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<String>
|
||||
{
|
||||
self.session_id.clone()
|
||||
}
|
||||
|
||||
pub fn load_state( &self ) -> Option<Vec<u8>>
|
||||
{
|
||||
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<Vec<u8>> ) -> 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<Vec<u8>> )
|
||||
{
|
||||
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;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
let mut tmp = path.as_os_str().to_owned();
|
||||
tmp.push( ".tmp" );
|
||||
let tmp = PathBuf::from( tmp );
|
||||
{
|
||||
let mut f = fs::OpenOptions::new()
|
||||
.write( true )
|
||||
.create( true )
|
||||
.truncate( true )
|
||||
.mode( 0o600 )
|
||||
.open( &tmp )?;
|
||||
f.write_all( bytes )?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
fs::rename( &tmp, path )
|
||||
}
|
||||
|
||||
fn pid_alive( pid: u32 ) -> bool
|
||||
{
|
||||
Path::new( "/proc" ).join( pid.to_string() ).exists()
|
||||
}
|
||||
}
|
||||
|
||||
#[ 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_prefers_xdg_then_home()
|
||||
{
|
||||
let xdg = OsStr::new( "/tmp/xdg" );
|
||||
let home = OsStr::new( "/home/u" );
|
||||
assert_eq!( state_dir( "net.example.App", Some( xdg ), Some( home ) ), Some( PathBuf::from( "/tmp/xdg/net.example.App" ) ) );
|
||||
assert_eq!( state_dir( "net.example.App", None, Some( home ) ), Some( PathBuf::from( "/home/u/.local/state/net.example.App" ) ) );
|
||||
assert_eq!( state_dir( "net.example.App", Some( OsStr::new( "" ) ), Some( home ) ), Some( PathBuf::from( "/home/u/.local/state/net.example.App" ) ) );
|
||||
assert_eq!( state_dir( "net.example.App", None, None ), None );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn state_dir_rejects_bad_ids()
|
||||
{
|
||||
let home = OsStr::new( "/home/u" );
|
||||
assert_eq!( state_dir( "", None, Some( home ) ), None );
|
||||
assert_eq!( state_dir( "a/b", None, Some( home ) ), None );
|
||||
assert_eq!( state_dir( "..", None, Some( home ) ), 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 ) );
|
||||
}
|
||||
|
||||
#[ 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" );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user