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
+160 -1
View File
@@ -337,6 +337,161 @@ pub trait App: 'static
/// Apply a message to the application state.
fn update( &mut self, msg: Self::Message );
/// Stable application identifier, reverse-DNS style
/// (`"net.liberux.settings"`, `"io.eydos.Calls"`), constant for the
/// life of the process.
///
/// The runtime uses it in three places:
///
/// * `xdg_toplevel.set_app_id` on the main window, so the compositor,
/// the dock and the task switcher can match the window to its
/// `.desktop` entry (use the desktop file's name minus `.desktop`,
/// or its `StartupWMClass`);
/// * the accessibility tree — AccessKit / AT-SPI2 report it as the
/// application name;
/// * the on-disk session directory `$XDG_STATE_HOME/<app_id>/`
/// where [`save_state`](Self::save_state) is persisted.
///
/// It must not be empty and must not contain `/`; the runtime then
/// disables session persistence and logs once. Layer-shell and
/// session-lock components still return one (it names them in the
/// a11y tree), even though they never get a toplevel. The `app_id`
/// element of the deprecated [`window_config`](Self::window_config)
/// tuple is ignored in favour of this method — keep both pointing at
/// the same `const`.
///
/// ```rust
/// # use ltk::{ App, Element, text };
/// # #[ derive( Clone ) ] enum Msg {}
/// # struct Notes;
/// const APP_ID: &str = "net.example.Notes";
///
/// impl App for Notes
/// {
/// # type Message = Msg;
/// # fn view( &self ) -> Element<Msg> { text( "" ).into() }
/// # fn update( &mut self, _: Msg ) {}
/// # fn save_state( &self ) -> Option<Vec<u8>> { None }
/// # fn restore_state( &mut self, _: Vec<u8> ) {}
/// fn app_id( &self ) -> &str { APP_ID }
/// }
/// ```
fn app_id( &self ) -> &str;
/// Serialize the part of the state worth surviving a restart.
///
/// Return the bytes the runtime should persist, or `None` when there
/// is nothing to bring back (a shell panel, a lock screen, a
/// stateless tool). The encoding is the app's own — JSON, CBOR, a
/// hand-rolled line format — ltk never inspects it and puts no serde
/// bound on the trait. Keep it small and cheap: the runtime calls
/// this
///
/// * every ~30 s while the app runs, touching the disk only when
/// the bytes differ from the last save (a UI-only change that
/// round-trips to identical bytes costs one comparison);
/// * when the window closes — after
/// [`on_close_requested`](Self::on_close_requested) allowed it or
/// [`requested_exit`](Self::requested_exit) returned `true`;
/// * on `SIGTERM` / `SIGINT`, which the runtime turns into a clean
/// exit of the event loop instead of letting the process die.
///
/// The bytes are written atomically (temp file + rename, mode
/// `0600`) to `$XDG_STATE_HOME/<app_id>/state.bin`
/// (`~/.local/state/<app_id>/state.bin` when the variable is unset)
/// next to a `session.json` holding the compositor's
/// `xdg-session-management-v1` session id and a clean-exit marker.
/// Returning `None` removes a stale `state.bin`. Only
/// [`ShellMode::Window`] apps (and the deprecated
/// [`window_config`](Self::window_config) path) are persisted;
/// layer and session-lock surfaces are skipped silently, so shell
/// components simply return `None`.
///
/// The file is plain on disk: never put secrets in it (passwords
/// belong to the secure-mode `text_edit` and its wiped buffers),
/// and keep large blobs (images, caches) elsewhere. The signal
/// handler only covers threads started after [`run`](crate::run)
/// was entered; a thread the app spawned earlier that receives
/// `SIGTERM` still terminates the process — block the signal in
/// such threads or start them from inside the app.
///
/// [`restore_state`](Self::restore_state) explains when — and when
/// not — the bytes come back.
///
/// ```rust
/// use ltk::{ App, Element, text };
/// use serde::{ Deserialize, Serialize };
///
/// #[ derive( Clone ) ]
/// enum Msg { Tab( usize ) }
///
/// #[ derive( Serialize, Deserialize ) ]
/// struct Persisted { version: u32, tab: usize, draft: String }
///
/// struct Editor { tab: usize, draft: String, cursor_blink: bool }
///
/// impl App for Editor
/// {
/// type Message = Msg;
///
/// fn app_id( &self ) -> &str { "net.example.Editor" }
///
/// fn save_state( &self ) -> Option<Vec<u8>>
/// {
/// // Only what a relaunch needs — the blink phase stays out.
/// let p = Persisted { version: 1, tab: self.tab, draft: self.draft.clone() };
/// serde_json::to_vec( &p ).ok()
/// }
///
/// fn restore_state( &mut self, state: Vec<u8> )
/// {
/// // Tolerate an older or corrupt file: keep the defaults.
/// if let Ok( p ) = serde_json::from_slice::<Persisted>( &state )
/// {
/// self.tab = p.tab;
/// self.draft = p.draft;
/// }
/// }
/// # fn view( &self ) -> Element<Msg> { text( self.draft.clone() ).into() }
/// # fn update( &mut self, msg: Msg ) { let Msg::Tab( t ) = msg; self.tab = t; }
/// }
/// ```
fn save_state( &self ) -> Option<Vec<u8>>;
/// Re-apply bytes previously returned by [`save_state`](Self::save_state).
///
/// Called at most once per process, synchronously inside
/// [`run`](crate::run) / [`try_run`](crate::try_run), before the
/// window is created and before the first [`view`](Self::view) — the
/// first frame already shows the restored state. It is **not**
/// called on an ordinary launch. The runtime restores app state only
/// when
///
/// * the process is relaunched as part of a session restore: the
/// shell sets `LTK_SESSION_RESTORE=1` in the environment (the
/// runtime removes the variable before the app can spawn
/// children); or
/// * the previous run of this `app_id` did not exit cleanly (crash,
/// `SIGKILL`, power loss): its `session.json` still says
/// `clean_exit: false`.
///
/// This mirrors Android's saved-instance-state contract: opening the
/// app from the launcher gives a fresh instance, coming back after
/// the system killed it lands where the user left off. Window
/// geometry (size, position, workspace) is separate — the compositor
/// restores it through `xdg-session-management-v1` on *every*
/// launch, plain ones included, using the session id ltk stores.
///
/// `state` is exactly what `save_state` produced, possibly by an
/// older build: validate, version, and fall back to defaults instead
/// of panicking. Nothing is called when no state file exists or the
/// app returned `None` last time. Shell components never receive
/// this call and implement it as a no-op.
///
/// See [`save_state`](Self::save_state) for the file layout and a
/// worked example.
fn restore_state( &mut self, state: Vec<u8> );
/// Tell the runtime which surfaces *could* have changed visibly as a
/// result of [`update`](Self::update) being called with this message.
///
@@ -777,7 +932,8 @@ pub trait App: 'static
/// Return `Some(( title, app_id ))` to force an XDG toplevel window instead of
/// layer-shell overlay. The compositor will display the title in the title bar
/// and use the app_id for taskbar/icon matching. Return `None` (default) to
/// use layer-shell when available.
/// use layer-shell when available. The `app_id` element is ignored:
/// [`app_id`](Self::app_id) names the window.
///
/// **Deprecated**: Use [`shell_mode`](Self::shell_mode) instead.
fn window_config( &self ) -> Option<( &str, &str )> { None }
@@ -961,6 +1117,9 @@ pub fn run<A: App>( app: A )
/// # struct MyApp;
/// # impl App for MyApp {
/// # type Message = Msg;
/// # fn app_id( &self ) -> &str { "net.example.MyApp" }
/// # fn save_state( &self ) -> Option<Vec<u8>> { None }
/// # fn restore_state( &mut self, _: Vec<u8> ) {}
/// # fn view( &self ) -> Element<Msg> { button( "x" ).into() }
/// # fn update( &mut self, _: Msg ) {}
/// # }