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)
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

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:
2026-08-15 10:16:30 +02:00
parent 78053b8b01
commit ccf07de593
35 changed files with 1539 additions and 29 deletions
+360
View File
@@ -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" );
}
}