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)
Some checks failed
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

View File

@@ -33,12 +33,14 @@ Where things live under `src/`, one line each:
- `core.rs``UiSurface`, runtime-free embedding.
- `draw/` — the per-frame drawing pipeline shared by both backends.
- `egl_context.rs` — EGL bootstrap for the GPU path.
- `event_loop/` — the Wayland run loop: frame scheduling, invalidation, clipboard / data device, text editing and IME, tooltips, focus, subsurfaces, perf guardrails.
- `event_loop/` — the Wayland run loop: frame scheduling, invalidation, clipboard / data device, text editing and IME, tooltips, focus, subsurfaces, session management, perf guardrails.
- `gles_render/` — GPU backend (EGL + GLES2 / GLES3).
- `input/` — pointer, keyboard and touch handling, the gesture machine, dispatch.
- `layout/` — composable arrangers for `Element` trees.
- `protocol/` — in-tree `wayland-scanner` bindings for protocols `wayland-protocols` does not generate yet (`xdg-session-management-v1`).
- `render/` — software rendering surface used by every widget.
- `secure_mem.rs` — volatile wipe of secret buffers behind `TextEdit`'s secure mode.
- `session_state.rs``$XDG_STATE_HOME/<app_id>/` session id + app-state files (atomic writes, clean-exit marker).
- `system_fonts.rs` — primary-font resolution and the per-glyph fallback chain.
- `text_shaping.rs` — BiDi reordering and rustybuzz (HarfBuzz) shaping.
- `theme/` — theme documents, slot stores, the embedded fallback.
@@ -68,8 +70,10 @@ In practice, that model is easiest to adopt in three steps:
**Always implement**
- `type Message` — your message enum.
- `app_id(&self) -> &str` — reverse-DNS id shared by the toplevel `app_id`, the a11y tree and the `$XDG_STATE_HOME/<app_id>/` session directory.
- `view(&self) -> Element<Msg>` — main surface contents.
- `update(&mut self, msg: Msg)` — state transitions.
- `save_state(&self) -> Option<Vec<u8>>` / `restore_state(&mut self, Vec<u8>)` — opaque bytes the runtime persists (every 30 s when changed, on close, on `SIGTERM`/`SIGINT`) and hands back before the first frame on a session restore or after an unclean exit — never on a plain launch. `None` / no-op for shell components and stateless tools.
**Implement when your app is multi-surface**
@@ -102,6 +106,7 @@ In practice, that model is easiest to adopt in three steps:
**Implement for window / toplevel lifecycle**
- `app_id()` — also the key of the compositor-side `xdg-session-management-v1` session; ltk issues `restore_toplevel` before the first commit so size/position come back on every launch.
- `window_config()` — deprecated forced-window escape hatch; prefer `shell_mode()`.
- `on_close_requested()` — return `false` to veto a close (compositor request, titlebar button, layer-shell closed event).
- `on_toplevel_event(event)` — open / close notifications from `ext-foreign-toplevel-list-v1`, keyed by a stable handle id.
@@ -109,7 +114,7 @@ In practice, that model is easiest to adopt in three steps:
Relatedly, `ltk::try_run( app )` is the fallible variant of `ltk::run` — it returns a `RunError` (no Wayland connection, missing protocol) instead of aborting, for apps that want a CLI fallback or a clean diagnostic.
The defaults for everything else are sensible enough that a minimal app overrides only the four methods in the first group.
The defaults for everything else are sensible enough that a minimal app overrides only the items in the first group.
Another way to read the trait is by API layer:
@@ -321,6 +326,8 @@ Downstream consumers shipping into regulated environments (EN 301 549, WCAG 2.1
**xdg-activation-v1 — wired in.** Both directions work: a token found in `$XDG_ACTIVATION_TOKEN` at startup is used to activate the app's own window once it maps (so an external launcher can raise an ltk window with focus), and an app that spawns children requests fresh tokens through `App::take_activation_requests` and receives them via `App::on_activation_token` to place in the child's environment.
**xdg-session-management-v1 — wired in (client side).** 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" )`, so the compositor restores geometry on every launch. The session id from `created` is persisted to `$XDG_STATE_HOME/<app_id>/session.json` next to `state.bin`, the app's own `App::save_state` bytes (saved every 30 s when changed, on close and on `SIGTERM`/`SIGINT`, which the runtime now turns into a clean exit). App state is handed back through `App::restore_state` only for reason `session_restore` (`LTK_SESSION_RESTORE=1` in the environment, set by the shell) or `recover` (previous run left `clean_exit: false`) — a plain launch starts fresh, Android-style. Layer-shell and session-lock surfaces have no toplevel and skip all of it. Compositors without the global lose only the geometry half; the file-based state and reason detection still work. Multi-toplevel sessions and the compositor implementation in forge are tracked separately.
**Fractional scale — deferred.** `wp_fractional_scale_v1` (so 125 % / 150 % outputs render natively instead of via compositor downscale) remains tracked as upcoming protocol work.
**Software/GLES parity gaps — see [`docs/backends.md`](./backends.md).** The software backend renders gradients as a flat fill from the first stop, skips outer and inset shadows and backdrop blur, and hard-cuts the bottom-edge fade; `oklab` gradient interpolation falls back to linear-light on both backends. The capability matrix is the canonical per-feature table and must be updated in the same patch that closes any of these gaps.

View File

@@ -23,6 +23,7 @@ reference, see [`docs/widgets.md`](./widgets.md). For theme JSON, see
- [Toast / OSD with auto-expiry](#toast--osd-with-auto-expiry)
- [Tab navigation between widgets](#tab-navigation-between-widgets)
- [Multi-screen app via sub-state pattern](#multi-screen-app-via-sub-state-pattern)
- [Surviving relaunch: session state](#surviving-relaunch-session-state)
- [Embedding ltk without `ltk::run`](#embedding-ltk-without-ltkrun)
- [Custom CPU drawing and path clipping](#custom-cpu-drawing-and-path-clipping)
- [Projecting an externally-laid-out view tree](#projecting-an-externally-laid-out-view-tree)
@@ -175,6 +176,10 @@ impl App for LoginApp
{
type Message = Msg;
fn app_id( &self ) -> &str { "net.example.Login" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg>
{
column()
@@ -418,6 +423,10 @@ impl App for LauncherApp
{
type Message = Msg;
fn app_id( &self ) -> &str { "net.example.Launcher" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg>
{
let mut grid = grid::<Msg>( 4 )
@@ -565,6 +574,10 @@ impl App for AppState
{
type Message = Msg;
fn app_id( &self ) -> &str { "net.example.Toast" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg> { self.main_view() }
fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
@@ -665,6 +678,10 @@ impl App for LoginApp
{
type Message = Msg;
fn app_id( &self ) -> &str { "net.example.Login" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg>
{
column()
@@ -752,6 +769,26 @@ impl App for AppState
{
type Message = AppMsg;
fn app_id( &self ) -> &str { "net.example.MultiScreen" }
// Persist only the routing; each screen would add its own line.
fn save_state( &self ) -> Option<Vec<u8>>
{
let s = match self.current { Screen::Home => "home", Screen::Settings => "settings", Screen::About => "about" };
Some( s.as_bytes().to_vec() )
}
fn restore_state( &mut self, state: Vec<u8> )
{
match state.as_slice()
{
b"home" => self.current = Screen::Home,
b"settings" => self.current = Screen::Settings,
b"about" => self.current = Screen::About,
_ => {}
}
}
fn view( &self ) -> Element<AppMsg>
{
let body = match self.current
@@ -798,6 +835,88 @@ per screen.
---
## Surviving relaunch: session state
The runtime persists whatever `App::save_state` returns and hands it
back through `App::restore_state` before the first frame — but only
when the shell relaunches the app as part of a session restore
(`LTK_SESSION_RESTORE=1` in the environment) or when the previous run
did not exit cleanly. A plain launch starts fresh, Android-style;
window size and position still come back on every launch through
`xdg-session-management-v1`.
The bytes are yours: version them, and treat a parse failure as "keep
the defaults". No serde needed for small state.
```rust,no_run
# use ltk::{ column, text, text_edit, App, Element };
#[derive(Clone)]
enum Msg { Tab( usize ), Draft( String ) }
struct Editor { tab: usize, draft: String }
impl App for Editor
{
type Message = Msg;
fn app_id( &self ) -> &str { "net.example.Editor" }
fn save_state( &self ) -> Option<Vec<u8>>
{
// Line 1 is the format version; the draft goes last so it may
// contain newlines.
Some( format!( "1\n{}\n{}", self.tab, self.draft ).into_bytes() )
}
fn restore_state( &mut self, state: Vec<u8> )
{
let Ok( text ) = String::from_utf8( state ) else { return };
let mut parts = text.splitn( 3, '\n' );
if parts.next() != Some( "1" ) { return; }
if let Some( tab ) = parts.next().and_then( |t| t.parse().ok() ) { self.tab = tab; }
if let Some( draft ) = parts.next() { self.draft = draft.to_string(); }
}
fn view( &self ) -> Element<Msg>
{
column()
.push( text( format!( "tab {}", self.tab ) ) )
.push( text_edit( "Draft", &self.draft ).on_change( Msg::Draft ) )
.into()
}
fn update( &mut self, msg: Msg )
{
match msg
{
Msg::Tab( t ) => self.tab = t,
Msg::Draft( d ) => self.draft = d,
}
}
}
```
The runtime saves in three situations: every ~30 s while the bytes
differ from the last save, when the window closes, and on `SIGTERM` /
`SIGINT` (which ltk turns into a clean exit of the event loop). Files
land in `$XDG_STATE_HOME/<app_id>/` — `state.bin` for your bytes,
`session.json` for the compositor session id and the clean-exit marker.
To try it: run the app, change something, `kill -TERM $(pidof my-app)`,
then start it again with `LTK_SESSION_RESTORE=1 my-app` — the state is
back. Start it without the variable and it opens fresh (the window
still lands where you left it). `kill -KILL` instead of `-TERM` and the
next plain launch restores too, because the marker says the last run
never exited cleanly.
Shell components (layer-shell panels, lock screens) have no toplevel and
are never persisted: return `None` and leave `restore_state` empty.
**See also**: `App::save_state` / `App::restore_state` rustdoc for the
full contract, and [`docs/architecture.md`](./architecture.md#known-gaps-and-non-goals) for the protocol status.
---
## Embedding ltk without `ltk::run`
A compositor or embedder that already owns the Wayland connection and

View File

@@ -149,6 +149,25 @@ impl App for CounterApp
{
type Message = Msg;
// Names the window for the compositor and the a11y tree, and the
// `$XDG_STATE_HOME/<app_id>/` directory the runtime saves state in.
fn app_id( &self ) -> &str { "net.example.Counter" }
// Opaque bytes ltk persists for you; handed back only on a session
// restore or after a crash — a plain launch starts fresh.
fn save_state( &self ) -> Option<Vec<u8>>
{
Some( self.value.to_string().into_bytes() )
}
fn restore_state( &mut self, state: Vec<u8> )
{
if let Some( v ) = String::from_utf8( state ).ok().and_then( |s| s.parse().ok() )
{
self.value = v;
}
}
fn view( &self ) -> Element<Msg>
{
column::<Msg>()
@@ -306,7 +325,7 @@ In practice, most first apps only need a small subset of the surface area.
Start here:
- `App`
- `App` — `app_id`, `view`, `update`, `save_state` / `restore_state`
- `Element<Msg>`
- `button`, `text`, `text_edit`, `img_widget`
- `column`, `row`, `stack`, `grid`, `spacer`
@@ -418,6 +437,20 @@ Use `core::UiSurface` when you want `ltk`'s layout/drawing/hit-testing without
There is coverage for that path in `tests/core_surface.rs`.
## Session state
`ltk` owns the plumbing, you own the bytes. `save_state` returns whatever a
relaunch needs (any encoding — the runtime never looks inside) and the
runtime writes it to `$XDG_STATE_HOME/<app_id>/state.bin` every 30 s when it
changed, when the window closes and on `SIGTERM` / `SIGINT`. `restore_state`
gets those bytes back before the first frame — but only when the shell
relaunches the app as part of a session restore (`LTK_SESSION_RESTORE=1`) or
when the previous run did not exit cleanly. Opening the app from the launcher
starts fresh; window size and position still come back on every launch
because the compositor restores them through `xdg-session-management-v1`.
Shell components (panels, lock screens) return `None` and no-op. See the
cookbook recipe *Surviving relaunch: session state* for a worked example.
## Current assumptions and rough edges
This repo is usable, but a few current behaviours are worth knowing up front: