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:
226
src/event_loop/session.rs
Normal file
226
src/event_loop/session.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! `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<XdgSessionV1>,
|
||||
pub toplevel_session: Option<XdgToplevelSessionV1>,
|
||||
/// `None` when persistence is off: layer / lock surfaces, an unusable
|
||||
/// app_id, a concurrent instance, or after `replaced`.
|
||||
pub store: Option<StateStore>,
|
||||
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<A: App>( 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<A: App>( &mut self, globals: &GlobalList, qh: &QueueHandle<AppData<A>> )
|
||||
{
|
||||
let Some( store ) = &self.store else { return };
|
||||
let manager: Option<XdgSessionManagerV1> = 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<A: App>( &mut self, window: &Window, qh: &QueueHandle<AppData<A>> )
|
||||
{
|
||||
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<A: App>( &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<A: App>( &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<A: App>( handle: &LoopHandle<'static, AppData<A>> ) -> 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<A>|
|
||||
{
|
||||
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<A: App>( handle: &LoopHandle<'static, AppData<A>> ) -> Result<(), RunError>
|
||||
{
|
||||
handle
|
||||
.insert_source( Timer::from_duration( SAVE_INTERVAL ), |_, _, data: &mut AppData<A>|
|
||||
{
|
||||
data.session.periodic_save( &data.app );
|
||||
TimeoutAction::ToDuration( SAVE_INTERVAL )
|
||||
} )
|
||||
.map_err( |e| RunError::EventLoop( format!( "save timer insert_source: {e:?}" ) ) )?;
|
||||
Ok( () )
|
||||
}
|
||||
|
||||
impl<A: App> Dispatch<XdgSessionManagerV1, ()> for AppData<A>
|
||||
{
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &XdgSessionManagerV1,
|
||||
_event: xdg_session_manager_v1::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> Dispatch<XdgSessionV1, ()> for AppData<A>
|
||||
{
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_proxy: &XdgSessionV1,
|
||||
event: xdg_session_v1::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
)
|
||||
{
|
||||
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<A: App> Dispatch<XdgToplevelSessionV1, ()> for AppData<A>
|
||||
{
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &XdgToplevelSessionV1,
|
||||
_event: xdg_toplevel_session_v1::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
)
|
||||
{
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user