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:
+160
-1
@@ -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 ) {}
|
||||
/// # }
|
||||
|
||||
Reference in New Issue
Block a user