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

@@ -6,6 +6,10 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
### Added ### Added
- **`xdg-session-management-v1` (client)** — before the first commit of a `ShellMode::Window` toplevel the runtime binds `xdg_session_manager_v1`, issues `get_session( reason, stored_id )` and `restore_toplevel( toplevel, "main" )`, so a supporting compositor restores window geometry on every launch. Bindings are generated in-tree from the vendored XML under `protocols/` with `wayland-scanner` (`src/protocol/`).
- **Runtime session persistence** — `$XDG_STATE_HOME/<app_id>/session.json` (compositor session id, clean-exit marker, pid) plus `state.bin` (the bytes from `App::save_state`), written atomically with mode `0600`; saved every 30 s when the bytes changed, on close and on signal; handed back through `App::restore_state` before the first frame only on a session restore (`LTK_SESSION_RESTORE=1`) or after an unclean exit. Module `src/session_state.rs`.
- **Clean exit on `SIGTERM` / `SIGINT`** — the runtime installs a calloop signal source and leaves the event loop instead of dying, so `save_state` runs and `ltk::run` returns.
- **`Slider::on_release` / `VSlider::on_release`** — fired once with the final value when the drag ends, so an app can keep an expensive commit (a subprocess, a D-Bus round trip, a compositor reconfigure) off the per-motion `on_change` path and still move the thumb live. The gesture machine emits it from the slider branch of `on_release`; `on_change` alone behaves exactly as before. - **`Slider::on_release` / `VSlider::on_release`** — fired once with the final value when the drag ends, so an app can keep an expensive commit (a subprocess, a D-Bus round trip, a compositor reconfigure) off the per-motion `on_change` path and still move the thumb live. The gesture machine emits it from the slider branch of `on_release`; `on_change` alone behaves exactly as before.
- **`Viewport::local_viewport()`** — resolve the child's viewport-relative (`vw` / `vh` / `vmin`) and fluid `Length`s against the viewport's own rect instead of the root layout viewport the sub-canvas inherits. For fixed-size floating mini-UIs (a phone-shaped panel pinned to a corner of a desktop-wide surface) whose content is calibrated against the panel rect; scroll-like clips should keep the default inheritance. - **`Viewport::local_viewport()`** — resolve the child's viewport-relative (`vw` / `vh` / `vmin`) and fluid `Length`s against the viewport's own rect instead of the root layout viewport the sub-canvas inherits. For fixed-size floating mini-UIs (a phone-shaped panel pinned to a corner of a desktop-wide surface) whose content is calibrated against the panel rect; scroll-like clips should keep the default inheritance.
- **`ListItem::height( impl Into<Length> )` / `ListItem::font_size( impl Into<Length> )`** — override the theme row height (floored at the label's rendered height so text never clips) and the primary-label font size, mirroring the `Toggle` / `Radio` `height()` builders, so dense menus can trade the touch-target generosity for row density. - **`ListItem::height( impl Into<Length> )` / `ListItem::font_size( impl Into<Length> )`** — override the theme row height (floored at the label's rendered height so text never clips) and the primary-label font size, mirroring the `Toggle` / `Radio` `height()` builders, so dense menus can trade the touch-target generosity for row density.
@@ -32,6 +36,7 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
### Changed ### Changed
- **Breaking: `App::app_id`, `App::save_state` and `App::restore_state` are new mandatory trait methods** — every implementor must add them (shell components: constant id, `None`, no-op). `xdg_toplevel.set_app_id` and the AccessKit application name now come from `app_id()` (previously `"ltk"` / `"ltk-app"`); the `app_id` element of the deprecated `window_config` tuple is ignored. Crate version bumped to 0.3.0.
- **`Button::icon_size` takes `impl Into<Length>`** and resolves through `Canvas::resolve_geom`, joining `font_size` / `height` / `width`. A bare `f32` still means `Length::px` — every existing call keeps its exact size — but a caller can now pass `Length::widget( n )` to have an icon button follow the widget-scaling mode the way stock icons do. Without it an explicit `icon_size` was the one geometry setter that ignored the mode, so a 21 px back arrow sat next to a `list_item` chevron that fluid sizing had grown well past 21 and looked visibly smaller. - **`Button::icon_size` takes `impl Into<Length>`** and resolves through `Canvas::resolve_geom`, joining `font_size` / `height` / `width`. A bare `f32` still means `Length::px` — every existing call keeps its exact size — but a caller can now pass `Length::widget( n )` to have an icon button follow the widget-scaling mode the way stock icons do. Without it an explicit `icon_size` was the one geometry setter that ignored the mode, so a 21 px back arrow sat next to a `list_item` chevron that fluid sizing had grown well past 21 and looked visibly smaller.
- **`Length::dp` now applies the density at resolution time, not at construction.** The value carries its design pixels in a new `LengthBase::Dp` variant and `resolve` multiplies by the density in effect when it runs, so a `set_density` change takes effect on the next paint without rebuilding the view's lengths — previously a `dp` value was frozen to the density read when it was constructed. Behaviour is unchanged for code that sets density once at startup. - **`Length::dp` now applies the density at resolution time, not at construction.** The value carries its design pixels in a new `LengthBase::Dp` variant and `resolve` multiplies by the density in effect when it runs, so a `set_density` change takes effect on the next paint without rebuilding the view's lengths — previously a `dp` value was frozen to the density read when it was constructed. Behaviour is unchanged for code that sets density once at startup.
- **The GLES image texture cache is now bounded** to 32 MiB of estimated GPU memory with least-recently-drawn eviction (the in-use entry is never evicted). Previously it grew without limit for the canvas' lifetime, so a stream of distinct buffers (photo carousel, video thumbnails) could exhaust GPU memory. - **The GLES image texture cache is now bounded** to 32 MiB of estimated GPU memory with least-recently-drawn eviction (the in-use entry is never evicted). Previously it grew without limit for the canvas' lifetime, so a stream of distinct buffers (photo carousel, video thumbnails) could exhaust GPU memory.

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ltk" name = "ltk"
version = "0.2.0" version = "0.3.0"
edition = "2021" edition = "2021"
rust-version = "1.85" rust-version = "1.85"
# MSRV-aware resolver: keep a fresh resolve on deps compatible with rust-version (Debian stable = 1.85). # MSRV-aware resolver: keep a fresh resolve on deps compatible with rust-version (Debian stable = 1.85).
@@ -34,7 +34,7 @@ chrono = { version = "0.4", features = ["clock"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
smithay-client-toolkit = { version = "0.20", features = ["calloop", "calloop-wayland-source", "xkbcommon"] } smithay-client-toolkit = { version = "0.20", features = ["calloop", "calloop-wayland-source", "xkbcommon"] }
calloop = "0.14" calloop = { version = "0.14", features = ["signals"] }
calloop-wayland-source = "0.4" calloop-wayland-source = "0.4"
tiny-skia = "0.12" tiny-skia = "0.12"
fontdue = "=0.9.3" fontdue = "=0.9.3"
@@ -48,6 +48,7 @@ rust-i18n = "3"
ignore = "=0.4.23" ignore = "=0.4.23"
wayland-protocols = { version = "0.32", features = ["client", "unstable", "staging"] } wayland-protocols = { version = "0.32", features = ["client", "unstable", "staging"] }
wayland-egl = "0.32" wayland-egl = "0.32"
wayland-scanner = "0.31"
khronos-egl = { version = "6", features = ["dynamic"] } khronos-egl = { version = "6", features = ["dynamic"] }
glow = "0.17" glow = "0.17"
raw-window-handle = "0.6" raw-window-handle = "0.6"

View File

@@ -39,7 +39,7 @@ doc:
install: doc install: doc
install -d $(REGISTRY) install -d $(REGISTRY)
cp -r src benches locales Cargo.toml liberux.toml $(REGISTRY)/ cp -r src benches locales protocols Cargo.toml liberux.toml $(REGISTRY)/
cp debian/cargo-checksum.json $(REGISTRY)/.cargo-checksum.json cp debian/cargo-checksum.json $(REGISTRY)/.cargo-checksum.json
install -d $(DOCDIR) install -d $(DOCDIR)
cp -r target/doc/* $(DOCDIR)/ cp -r target/doc/* $(DOCDIR)/

View File

@@ -109,6 +109,10 @@ impl App for CounterApp
{ {
type Message = Msg; type Message = Msg;
fn app_id( &self ) -> &str { "net.example.Counter" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg> fn view( &self ) -> Element<Msg>
{ {
column::<Msg>() column::<Msg>()
@@ -137,6 +141,11 @@ fn main()
} }
``` ```
`app_id` names the window and the `$XDG_STATE_HOME/<app_id>/` session
directory; `save_state` / `restore_state` are the opaque bytes ltk persists
for session restore and crash recovery — return `None` when you have nothing
to keep.
## Requirements ## Requirements
`ltk` currently assumes: `ltk` currently assumes:

7
debian/changelog vendored
View File

@@ -1,3 +1,10 @@
ltk (0.3.0-1) unstable; urgency=low
* New upstream release: xdg-session-management-v1 client support (in-tree wayland-scanner bindings under protocols/), runtime session persistence in $XDG_STATE_HOME/<app_id>/ (session id, clean-exit marker, App::save_state bytes; saved periodically, on close and on SIGTERM/SIGINT), clean exit on signals.
* Breaking: App::app_id, App::save_state and App::restore_state are new mandatory trait methods; xdg_toplevel app_id and the AccessKit name now come from App::app_id.
-- Pedro M. de Echanove Pasquin <pedro.echanove@liberux.net> Sat, 15 Aug 2026 12:00:00 +0200
ltk (0.2.0-1) unstable; urgency=low ltk (0.2.0-1) unstable; urgency=low
* New upstream release: embedder primitives for hosting an externally-laid-out widget tree (path/rect clipping, RGBA readback, standalone text measurement, CPU draw source, RichText). * New upstream release: embedder primitives for hosting an externally-laid-out widget tree (path/rect clipping, RGBA readback, standalone text measurement, CPU draw source, RichText).

29
debian/copyright vendored
View File

@@ -11,6 +11,15 @@ Files: debian/*
Copyright: 2026 Liberux Labs, S. L. <info@liberux.net> Copyright: 2026 Liberux Labs, S. L. <info@liberux.net>
License: LGPL-2.1-only License: LGPL-2.1-only
Files: protocols/*
Copyright: 2018 Mike Blumenkrantz
2018 Samsung Electronics Co., Ltd
2018 Red Hat Inc.
License: MIT
Comment:
xdg-session-management-v1.xml, vendored verbatim from wayland-protocols
(staging) so the client bindings can be generated at build time.
Files: themes/default/branding/* Files: themes/default/branding/*
themes/default/icons/apps/* themes/default/icons/apps/*
themes/default/icons/app-default.svg themes/default/icons/app-default.svg
@@ -254,3 +263,23 @@ License: LGPL-3
The Adwaita cursors are alternatively available under the GNU Lesser The Adwaita cursors are alternatively available under the GNU Lesser
General Public License version 3. On Debian systems the complete text General Public License version 3. On Debian systems the complete text
of the LGPL version 3 is in `/usr/share/common-licenses/LGPL-3`. of the LGPL version 3 is in `/usr/share/common-licenses/LGPL-3`.
License: MIT
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
.
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

View File

@@ -33,12 +33,14 @@ Where things live under `src/`, one line each:
- `core.rs``UiSurface`, runtime-free embedding. - `core.rs``UiSurface`, runtime-free embedding.
- `draw/` — the per-frame drawing pipeline shared by both backends. - `draw/` — the per-frame drawing pipeline shared by both backends.
- `egl_context.rs` — EGL bootstrap for the GPU path. - `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). - `gles_render/` — GPU backend (EGL + GLES2 / GLES3).
- `input/` — pointer, keyboard and touch handling, the gesture machine, dispatch. - `input/` — pointer, keyboard and touch handling, the gesture machine, dispatch.
- `layout/` — composable arrangers for `Element` trees. - `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. - `render/` — software rendering surface used by every widget.
- `secure_mem.rs` — volatile wipe of secret buffers behind `TextEdit`'s secure mode. - `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. - `system_fonts.rs` — primary-font resolution and the per-glyph fallback chain.
- `text_shaping.rs` — BiDi reordering and rustybuzz (HarfBuzz) shaping. - `text_shaping.rs` — BiDi reordering and rustybuzz (HarfBuzz) shaping.
- `theme/` — theme documents, slot stores, the embedded fallback. - `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** **Always implement**
- `type Message` — your message enum. - `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. - `view(&self) -> Element<Msg>` — main surface contents.
- `update(&mut self, msg: Msg)` — state transitions. - `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** **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** **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()`. - `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_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. - `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. 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: 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-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. **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. **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) - [Toast / OSD with auto-expiry](#toast--osd-with-auto-expiry)
- [Tab navigation between widgets](#tab-navigation-between-widgets) - [Tab navigation between widgets](#tab-navigation-between-widgets)
- [Multi-screen app via sub-state pattern](#multi-screen-app-via-sub-state-pattern) - [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) - [Embedding ltk without `ltk::run`](#embedding-ltk-without-ltkrun)
- [Custom CPU drawing and path clipping](#custom-cpu-drawing-and-path-clipping) - [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) - [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; 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> fn view( &self ) -> Element<Msg>
{ {
column() column()
@@ -418,6 +423,10 @@ impl App for LauncherApp
{ {
type Message = Msg; 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> fn view( &self ) -> Element<Msg>
{ {
let mut grid = grid::<Msg>( 4 ) let mut grid = grid::<Msg>( 4 )
@@ -565,6 +574,10 @@ impl App for AppState
{ {
type Message = Msg; 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 view( &self ) -> Element<Msg> { self.main_view() }
fn overlays( &self ) -> Vec<OverlaySpec<Msg>> fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
@@ -665,6 +678,10 @@ impl App for LoginApp
{ {
type Message = Msg; 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> fn view( &self ) -> Element<Msg>
{ {
column() column()
@@ -752,6 +769,26 @@ impl App for AppState
{ {
type Message = AppMsg; 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> fn view( &self ) -> Element<AppMsg>
{ {
let body = match self.current 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` ## Embedding ltk without `ltk::run`
A compositor or embedder that already owns the Wayland connection and A compositor or embedder that already owns the Wayland connection and

View File

@@ -149,6 +149,25 @@ impl App for CounterApp
{ {
type Message = Msg; 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> fn view( &self ) -> Element<Msg>
{ {
column::<Msg>() column::<Msg>()
@@ -306,7 +325,7 @@ In practice, most first apps only need a small subset of the surface area.
Start here: Start here:
- `App` - `App` — `app_id`, `view`, `update`, `save_state` / `restore_state`
- `Element<Msg>` - `Element<Msg>`
- `button`, `text`, `text_edit`, `img_widget` - `button`, `text`, `text_edit`, `img_widget`
- `column`, `row`, `stack`, `grid`, `spacer` - `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`. 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 ## Current assumptions and rough edges
This repo is usable, but a few current behaviours are worth knowing up front: This repo is usable, but a few current behaviours are worth knowing up front:

View File

@@ -80,6 +80,10 @@ impl App for CarouselApp
{ {
type Message = Message; type Message = Message;
fn app_id( &self ) -> &str { "net.liberux.ltk.example.carousel" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Message> fn view( &self ) -> Element<Message>
{ {
let palette = ltk::theme_palette(); let palette = ltk::theme_palette();

View File

@@ -94,6 +94,10 @@ impl App for Demo
{ {
type Message = Msg; type Message = Msg;
fn app_id( &self ) -> &str { "net.liberux.ltk.example.clip_path" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg> fn view( &self ) -> Element<Msg>
{ {
// Cells side by side in landscape, stacked in portrait; the // Cells side by side in landscape, stacked in portrait; the

View File

@@ -155,6 +155,10 @@ impl App for Demo
{ {
type Message = Msg; type Message = Msg;
fn app_id( &self ) -> &str { "net.liberux.ltk.example.combo" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg> fn view( &self ) -> Element<Msg>
{ {
self.body() self.body()

View File

@@ -62,6 +62,10 @@ impl App for DialogApp
{ {
type Message = Msg; type Message = Msg;
fn app_id( &self ) -> &str { "net.liberux.ltk.example.dialog" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg> fn view( &self ) -> Element<Msg>
{ {
let palette = ltk::theme_palette(); let palette = ltk::theme_palette();

View File

@@ -52,6 +52,10 @@ impl App for InputsApp
{ {
type Message = Message; type Message = Message;
fn app_id( &self ) -> &str { "net.liberux.ltk.example.inputs" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Message> fn view( &self ) -> Element<Message>
{ {
let palette = ltk::theme_palette(); let palette = ltk::theme_palette();

View File

@@ -170,6 +170,28 @@ impl App for AppState
{ {
type Message = AppMsg; type Message = AppMsg;
fn app_id( &self ) -> &str { "net.liberux.ltk.example.mini_shell" }
fn save_state( &self ) -> Option<Vec<u8>>
{
let route = match self.route { Route::Home => "home", Route::Settings => "settings" };
Some( format!( "1\n{route}\n{}", self.brightness ).into_bytes() )
}
fn restore_state( &mut self, state: Vec<u8> )
{
let Ok( text ) = String::from_utf8( state ) else { return };
let mut lines = text.lines();
if lines.next() != Some( "1" ) { return; }
match lines.next()
{
Some( "home" ) => self.route = Route::Home,
Some( "settings" ) => self.route = Route::Settings,
_ => {}
}
if let Some( b ) = lines.next().and_then( |l| l.parse::<f32>().ok() ) { self.brightness = b.clamp( 0.0, 1.0 ); }
}
fn view( &self ) -> Element<AppMsg> fn view( &self ) -> Element<AppMsg>
{ {
match self.route match self.route

View File

@@ -116,6 +116,10 @@ impl App for PickerApp
{ {
type Message = Msg; type Message = Msg;
fn app_id( &self ) -> &str { "net.liberux.ltk.example.pickers" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg> fn view( &self ) -> Element<Msg>
{ {
let header = text( "ltk pickers" ) let header = text( "ltk pickers" )

View File

@@ -66,6 +66,10 @@ impl App for ResponsiveApp
{ {
type Message = Message; type Message = Message;
fn app_id( &self ) -> &str { "net.liberux.ltk.example.responsive" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Message> fn view( &self ) -> Element<Message>
{ {
let palette = ltk::theme_palette(); let palette = ltk::theme_palette();

View File

@@ -53,6 +53,23 @@ impl App for ScrollApp
{ {
type Message = Message; type Message = Message;
fn app_id( &self ) -> &str { "net.liberux.ltk.example.scroll" }
fn save_state( &self ) -> Option<Vec<u8>>
{
Some( match self.mode { Mode::List => b"list".to_vec(), Mode::Grid => b"grid".to_vec() } )
}
fn restore_state( &mut self, state: Vec<u8> )
{
match state.as_slice()
{
b"list" => self.mode = Mode::List,
b"grid" => self.mode = Mode::Grid,
_ => {}
}
}
fn view( &self ) -> Element<Message> fn view( &self ) -> Element<Message>
{ {
let palette = ltk::theme_palette(); let palette = ltk::theme_palette();

View File

@@ -92,6 +92,23 @@ impl App for ShowcaseApp
{ {
type Message = Message; type Message = Message;
fn app_id( &self ) -> &str { "net.liberux.ltk.example.showcase" }
fn save_state( &self ) -> Option<Vec<u8>>
{
Some( format!( "1\n{}\n{}\n{}", self.tab, self.slider_value, self.note ).into_bytes() )
}
fn restore_state( &mut self, state: Vec<u8> )
{
let Ok( text ) = String::from_utf8( state ) else { return };
let mut lines = text.splitn( 4, '\n' );
if lines.next() != Some( "1" ) { return; }
if let Some( tab ) = lines.next().and_then( |l| l.parse().ok() ) { self.tab = tab; }
if let Some( v ) = lines.next().and_then( |l| l.parse().ok() ) { self.slider_value = v; }
if let Some( note ) = lines.next() { self.note = note.to_string(); }
}
fn view( &self ) -> Element<Message> fn view( &self ) -> Element<Message>
{ {
// Pull text colours from the active theme — the runtime // Pull text colours from the active theme — the runtime

View File

@@ -48,6 +48,10 @@ impl App for SlidersApp
{ {
type Message = Msg; type Message = Msg;
fn app_id( &self ) -> &str { "net.liberux.ltk.example.sliders" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg> fn view( &self ) -> Element<Msg>
{ {
// Pill dimensions chosen so the Glass insets read at their // Pill dimensions chosen so the Glass insets read at their

View File

@@ -64,6 +64,11 @@ impl App for WidgetsApp
{ {
type Message = Msg; type Message = Msg;
// Stateless demo: nothing worth restoring.
fn app_id( &self ) -> &str { "net.liberux.ltk.example.widgets" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg> fn view( &self ) -> Element<Msg>
{ {
// Pull text colours from the active theme so the example // Pull text colours from the active theme so the example

View File

@@ -0,0 +1,333 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="xdg_session_management_v1">
<copyright>
Copyright 2018 Mike Blumenkrantz
Copyright 2018 Samsung Electronics Co., Ltd
Copyright 2018 Red Hat Inc.
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
</copyright>
<description summary="Protocol for managing application sessions">
This description provides a high-level overview of the interplay between
the interfaces defined this protocol. For details, see the protocol
specification.
The xdg_session_manager protocol declares interfaces necessary to
allow clients to restore toplevel state from previous executions. The
xdg_session_manager_v1.get_session request can be used to obtain a
xdg_session_v1 resource representing the state of a set of toplevels.
Clients may obtain the session string to use in future calls through
the xdg_session_v1.created event. Compositors will use this string
as an identifiable token for future runs, possibly storing data about
the related toplevels in persistent storage. Clients that wish to
track sessions in multiple environments may use the $XDG_CURRENT_DESKTOP
environment variable.
Toplevels are managed through the xdg_session_v1.add_toplevel and
xdg_session_v1.remove_toplevel pair of requests. Clients will explicitly
request a toplevel to be restored according to prior state through the
xdg_session_v1.restore_toplevel request before the toplevel is mapped.
Compositors may store session information up to any arbitrary level, and
apply any limits and policies to the amount of data stored and its lifetime.
Clients must account for missing sessions and partial session restoration.
Warning! The protocol described in this file is currently in the testing
phase. Backward compatible changes may be added together with the
corresponding interface version bump. Backward incompatible changes can
only be done by creating a new major version of the extension.
</description>
<interface name="xdg_session_manager_v1" version="1">
<description summary="manage sessions for applications">
The xdg_session_manager_v1 interface defines base requests for creating and
managing a session for an application. Sessions persist across application
and compositor restarts unless explicitly destroyed. A session is created
for the purpose of maintaining an application's xdg_toplevel surfaces
across compositor or application restarts. The compositor should remember
as many states as possible for surfaces in a given session, but there is
no requirement for which states must be remembered.
Policies such as cache eviction are declared an implementation detail of
the compositor. Clients should account for no longer existing sessions.
</description>
<enum name="error">
<entry name="in_use" summary="a requested session is already in use"
value="1"/>
<entry name="invalid_session_id" summary="invalid session identifier"
value="2"/>
<entry name="invalid_reason" summary="invalid reason" value="3"/>
</enum>
<enum name="reason">
<description summary="reason for getting a session">
The reason may determine in what way a session restores the window
management state of associated toplevels.
For example newly launched applications might be launched on the active
workspace with restored size and position, while a recovered
application might restore additional state such as active workspace and
stacking order.
</description>
<entry name="launch" value="1">
<description summary="an app is newly launched">
A new app instance is launched, for example from an app launcher.
</description>
</entry>
<entry name="recover" value="2">
<description summary="an app recovered">
An app instance is recovering from for example a compositor or app crash.
</description>
</entry>
<entry name="session_restore" value="3">
<description summary="an app restored">
An app instance is restored, for example part of a restored session, or
restored from having been temporarily terminated due to resource
constraints.
</description>
</entry>
</enum>
<request name="destroy" type="destructor">
<description summary="Destroy this object">
Destroy the manager object. The existing session objects will be
unaffected.
</description>
</request>
<request name="get_session">
<description summary="create or restore a session">
Create a session object corresponding to either an existing session
identified by the given session identifier string or a new session.
While the session object exists, the session is considered to be "in
use".
If an identifier string represents a session that is currently actively
in use by the the same client, an 'in_use' error is raised. If some
other client is currently using the same session, the new session will
replace managing the associated state.
If the reason is not a valid enum entry, the 'invalid_reason' protocol
error is raised.
NULL is passed to initiate a new session. If a session_id is passed
which does not represent a valid session, the compositor treats it as if
NULL had been passed.
The session id string must be UTF-8 encoded. It is also limited by the
maximum length of wayland messages (around 4KB). The 'invalid_session_id'
protocol error will be raised if an invalid string is provided.
A client is allowed to have any number of in use sessions at the same
time.
</description>
<arg name="id" type="new_id" interface="xdg_session_v1"/>
<arg name="reason" type="uint" enum="reason"
summary="reason for session"/>
<arg name="session_id" type="string"
summary="the session to restore"
allow-null="true"/>
</request>
</interface>
<interface name="xdg_session_v1" version="1">
<description summary="A session for an application">
A xdg_session_v1 object represents a session for an application. While the
object exists, all surfaces which have been added to the session will
have states stored by the compositor which can be reapplied at a later
time. Two sessions cannot exist for the same identifier string.
States for surfaces added to a session are automatically updated by the
compositor when they are changed.
</description>
<enum name="error">
<entry name="name_in_use"
summary="toplevel name is already in use"
value="1"/>
<entry name="already_mapped"
summary="toplevel was already mapped when restored"
value="2"/>
<entry name="invalid_name"
summary="provided toplevel name is invalid"
value="3"/>
<entry name="already_added"
summary="toplevel already added"
value="4"/>
</enum>
<request name="destroy" type="destructor">
<description summary="Destroy the session">
Destroy a session object, preserving the current state but not continuing
to make further updates if state changes occur. This makes the associated
xdg_toplevel_session_v1 objects inert.
</description>
</request>
<request name="remove" type="destructor">
<description summary="Remove the session">
Remove the session, making it no longer available for restoration. A
compositor should in response to this request remove the data related to
this session from its storage.
</description>
</request>
<request name="add_toplevel">
<description summary="add a new surface to the session">
Attempt to add a given surface to the session. The passed name is used
to identify what window is being restored, and may be used to store
window specific state within the session.
The name given to the toplevel must not correspond to any previously
existing toplevel names in the session. If the name matches an already
known toplevel name in the session, a 'name_in_use' protocol error will
be raised.
The toplevel object must not be added more than once to any session
created by the client, otherwise the 'already_added' protocol error
will be raised.
This request will return a xdg_toplevel_session_v1 for later
manipulation. As this resource is created from an empty initial state,
compositors must not emit a xdg_toplevel_session_v1.restored event for
resources created through this request.
The name string must be UTF-8 encoded. It is also limited by the maximum
length of wayland messages (around 4KB). The 'invalid_name' protocol
error will be raised if an invalid string is provided.
</description>
<arg name="id" type="new_id" interface="xdg_toplevel_session_v1"/>
<arg name="toplevel" type="object" interface="xdg_toplevel"/>
<arg name="name" type="string" summary="name identifying the toplevel"/>
</request>
<request name="restore_toplevel">
<description summary="restore a surface state">
Inform the compositor that the toplevel associated with the passed name
should have its window management state restored.
If the toplevel name was previously granted to another xdg_toplevel,
the 'name_in_use' protocol error will be raised.
The toplevel object must not be added more than once to any session
created by the client, otherwise the 'already_added' protocol error
will be raised.
This request must be called prior to the first commit on the associated
wl_surface after creating the toplevel, otherwise an 'already_mapped'
error is raised.
As part of the initial configure sequence, if the toplevel was
successfully restored, a xdg_toplevel_session_v1.restored event is
emitted. If the toplevel name was not known in the session, this request
will be equivalent to the xdg_toplevel_session_v1.add_toplevel request,
and no such event will be emitted. See the xdg_toplevel_session_v1.restored
event for further details.
The name string must be UTF-8 encoded. It is also limited by the maximum
length of wayland messages (around 4KB). The 'invalid_name' protocol
error will be raised if an invalid string is provided.
</description>
<arg name="id" type="new_id" interface="xdg_toplevel_session_v1"/>
<arg name="toplevel" type="object" interface="xdg_toplevel"/>
<arg name="name" type="string" summary="name identifying the toplevel"/>
</request>
<request name="remove_toplevel">
<description summary="remove a surface from the session">
Remove a specified surface from the session and render any related
xdg_toplevel_session_v1 object inert. The compositor should remove any
data related to the toplevel in the corresponding session from its internal
storage.
The window is specified by its name in the session. The name string
must be encoded in UTF-8, and it is limited in size by the maximum
length of wayland messages (around 4KB).
</description>
<arg name="name" type="string" summary="name identifying the toplevel"/>
</request>
<event name="created">
<description summary="newly-created session id">
Emitted at most once some time after getting a new session object. It
means that no previous state was restored, and a new session was created.
The passed id can be persistently stored and used to restore previous
sessions.
</description>
<arg name="session_id" type="string"/>
</event>
<event name="restored">
<description summary="the session has been restored">
Emitted at most once some time after getting a new session object. It
means that previous state was at least partially restored. The same id
can again be used to restore previous sessions.
</description>
</event>
<event name="replaced">
<description summary="the session has been replaced">
Emitted at most once, if the session was taken over by some other
client. When this happens, the session and all its toplevel session
objects become inert, and should be destroyed.
</description>
</event>
</interface>
<interface name="xdg_toplevel_session_v1" version="1">
<description summary="A session for an application">
A xdg_toplevel_session_v1 resource acts as a handle for the given
toplevel in the session. It allows for receiving events after a
toplevel state was restored, and has the requests to manage them.
</description>
<request name="destroy" type="destructor">
<description summary="Destroy the object">
Destroy the object. This has no effect over window management of the
associated toplevel.
</description>
</request>
<request name="rename">
<description summary="change the name of toplevel session">
Renames the toplevel session. The new name can be used in subsequent requests
to identify this session object. The state associated with this toplevel
session will be preserved.
If the xdg_session_v1 already contains a toplevel with the specified name,
the 'name_in_use' protocol error will be raised.
</description>
<arg name="name" type="string" summary="new name to identify the toplevel"/>
</request>
<event name="restored">
<description summary="a toplevel's session has been restored">
The "restored" event is emitted prior to the first
xdg_toplevel.configure for the toplevel. It will only be emitted after
xdg_session_v1.restore_toplevel, and the initial empty surface state has
been applied, and it indicates that the surface's session is being
restored with this configure event.
</description>
</event>
</interface>
</protocol>

View File

@@ -337,6 +337,161 @@ pub trait App: 'static
/// Apply a message to the application state. /// Apply a message to the application state.
fn update( &mut self, msg: Self::Message ); 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 /// Tell the runtime which surfaces *could* have changed visibly as a
/// result of [`update`](Self::update) being called with this message. /// 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 /// 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 /// 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 /// 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. /// **Deprecated**: Use [`shell_mode`](Self::shell_mode) instead.
fn window_config( &self ) -> Option<( &str, &str )> { None } fn window_config( &self ) -> Option<( &str, &str )> { None }
@@ -961,6 +1117,9 @@ pub fn run<A: App>( app: A )
/// # struct MyApp; /// # struct MyApp;
/// # impl App for MyApp { /// # impl App for MyApp {
/// # type Message = Msg; /// # 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 view( &self ) -> Element<Msg> { button( "x" ).into() }
/// # fn update( &mut self, _: Msg ) {} /// # fn update( &mut self, _: Msg ) {}
/// # } /// # }

View File

@@ -103,6 +103,11 @@ pub struct AppData<A: App>
/// (resizes, scale changes) must not re-activate. /// (resizes, scale changes) must not re-activate.
pub activation_token_pending: Option<String>, pub activation_token_pending: Option<String>,
/// `xdg-session-management-v1` proxies plus the on-disk store; the
/// store is `None` when persistence is off (layer / lock surfaces,
/// unusable app_id, concurrent instance, or after `replaced`).
pub session: super::session::SessionRuntime,
/// `wl_data_device_manager` binding. `None` when the compositor /// `wl_data_device_manager` binding. `None` when the compositor
/// does not advertise the global, in which case copy / paste stays /// does not advertise the global, in which case copy / paste stays
/// process-local and inbound selections from other clients are /// process-local and inbound selections from other clients are

View File

@@ -11,6 +11,7 @@ pub( crate ) mod focus;
mod handlers; mod handlers;
pub( crate ) mod perf; pub( crate ) mod perf;
pub( crate ) mod repeat; pub( crate ) mod repeat;
pub( crate ) mod session;
pub( crate ) mod subsurface; pub( crate ) mod subsurface;
pub( crate ) mod surface; pub( crate ) mod surface;
pub( crate ) mod text_editing; pub( crate ) mod text_editing;

View File

@@ -51,7 +51,7 @@ pub( crate ) fn run<A: App>( app: A )
/// The dispatch loop's runtime errors still panic — they are non- /// The dispatch loop's runtime errors still panic — they are non-
/// recoverable once the surface is on screen, and the surface state /// recoverable once the surface is on screen, and the surface state
/// machine cannot be unwound cleanly from this entry point. /// machine cannot be unwound cleanly from this entry point.
pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError> pub( crate ) fn try_run<A: App>( mut app: A ) -> Result<(), RunError>
{ {
let conn = Connection::connect_to_env() let conn = Connection::connect_to_env()
.map_err( |e| RunError::NoWaylandConnection( format!( "{e}" ) ) )?; .map_err( |e| RunError::NoWaylandConnection( format!( "{e}" ) ) )?;
@@ -66,6 +66,10 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
.insert( event_loop.handle() ) .insert( event_loop.handle() )
.map_err( |e| RunError::EventLoop( format!( "WaylandSource::insert: {e:?}" ) ) )?; .map_err( |e| RunError::EventLoop( format!( "WaylandSource::insert: {e:?}" ) ) )?;
// Before any thread exists: signalfd only sees signals blocked in the
// thread that created it, and the mask is inherited by later threads.
super::session::install_signal_source( &event_loop.handle() )?;
let compositor = CompositorState::bind( &globals, &qh ) let compositor = CompositorState::bind( &globals, &qh )
.map_err( |e| RunError::MissingProtocol { name: "wl_compositor", detail: format!( "{e:?}" ) } )?; .map_err( |e| RunError::MissingProtocol { name: "wl_compositor", detail: format!( "{e:?}" ) } )?;
let shm = Shm::bind( &globals, &qh ) let shm = Shm::bind( &globals, &qh )
@@ -98,6 +102,26 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
let force_window = app.window_config() let force_window = app.window_config()
.map( |( t, id )| ( t.to_string(), id.to_string() ) ); .map( |( t, id )| ( t.to_string(), id.to_string() ) );
let session_enabled = force_window.is_some() || matches!( app.shell_mode(), crate::app::ShellMode::Window );
let mut session = if session_enabled
{
super::session::SessionRuntime::bootstrap( &mut app )
} else {
super::session::SessionRuntime::disabled()
};
if session_enabled
{
session.bind( &globals, &qh );
}
let app_id = app.app_id().to_string();
if let Some( ( _, ref cfg_id ) ) = force_window
{
if cfg_id != &app_id
{
eprintln!( "ltk: window_config app_id {cfg_id:?} differs from App::app_id {app_id:?}; App::app_id wins" );
}
}
let bind_xdg = |globals: &smithay_client_toolkit::reexports::client::globals::GlobalList, qh: &smithay_client_toolkit::reexports::client::QueueHandle<AppData<A>>| let bind_xdg = |globals: &smithay_client_toolkit::reexports::client::globals::GlobalList, qh: &smithay_client_toolkit::reexports::client::QueueHandle<AppData<A>>|
-> Result<XdgShell, RunError> -> Result<XdgShell, RunError>
{ {
@@ -132,16 +156,28 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
} }
}; };
let ( surface_kind, xdg_shell ) = if let Some( ( ref title, ref app_id ) ) = force_window // `restore_toplevel` must precede the first commit, so the session
// attach sits right before `commit()`.
let make_window = |xdg: &XdgShell, title: &str, session: &mut super::session::SessionRuntime, attach: bool|
{ {
let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh ); let surface = compositor.create_surface( &qh );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh ); let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( title.as_str() ); window.set_title( title );
window.set_app_id( app_id.as_str() ); window.set_app_id( app_id.as_str() );
apply_size_hint( &window ); apply_size_hint( &window );
apply_fullscreen( &window ); apply_fullscreen( &window );
if attach
{
session.attach_toplevel( &window, &qh );
}
window.commit(); window.commit();
window
};
let ( surface_kind, xdg_shell ) = if let Some( ( ref title, _ ) ) = force_window
{
let xdg = bind_xdg( &globals, &qh )?;
let window = make_window( &xdg, title.as_str(), &mut session, true );
( SurfaceKind::Window( window ), Some( xdg ) ) ( SurfaceKind::Window( window ), Some( xdg ) )
} else { } else {
// Use shell_mode() to determine surface type // Use shell_mode() to determine surface type
@@ -154,13 +190,8 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
( SurfaceKind::PendingLock, None ) ( SurfaceKind::PendingLock, None )
} }
ShellMode::Window => { ShellMode::Window => {
let xdg = bind_xdg( &globals, &qh )?; let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh ); let window = make_window( &xdg, "ltk", &mut session, true );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( "ltk" );
window.set_app_id( "ltk" );
apply_size_hint( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) ) ( SurfaceKind::Window( window ), Some( xdg ) )
} }
ShellMode::Layer( layer ) => { ShellMode::Layer( layer ) => {
@@ -180,13 +211,8 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
( SurfaceKind::Pending( cfg ), None ) ( SurfaceKind::Pending( cfg ), None )
} else { } else {
eprintln!( "ltk: wlr-layer-shell not available, falling back to xdg window" ); eprintln!( "ltk: wlr-layer-shell not available, falling back to xdg window" );
let xdg = bind_xdg( &globals, &qh )?; let xdg = bind_xdg( &globals, &qh )?;
let surface = compositor.create_surface( &qh ); let window = make_window( &xdg, "ltk", &mut session, false );
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
window.set_title( "ltk" );
window.set_app_id( "ltk" );
apply_size_hint( &window );
window.commit();
( SurfaceKind::Window( window ), Some( xdg ) ) ( SurfaceKind::Window( window ), Some( xdg ) )
} }
} }
@@ -218,9 +244,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
// platform adapter cannot be created (no daemon on the bus, // platform adapter cannot be created (no daemon on the bus,
// headless CI, etc.) — the runtime then runs with no // headless CI, etc.) — the runtime then runs with no
// accessibility tree, which is the previous behaviour. // accessibility tree, which is the previous behaviour.
let a11y_app_name = "ltk-app"; let a11y = crate::a11y::A11yState::try_new( &app_id, &app_id );
let a11y_app_id = "net.liberux.ltk";
let a11y = crate::a11y::A11yState::try_new( a11y_app_name, a11y_app_id );
// xdg-activation-v1: optional. Compositors that don't carry the // xdg-activation-v1: optional. Compositors that don't carry the
// global leave `activation_state` as `None` and the inbound / // global leave `activation_state` as `None` and the inbound /
// outbound activation paths silently degrade to no-ops. // outbound activation paths silently degrade to no-ops.
@@ -286,6 +310,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
text_input_secure: false, text_input_secure: false,
activation_state, activation_state,
activation_token_pending, activation_token_pending,
session,
data_device_manager, data_device_manager,
data_device: None, data_device: None,
clipboard_source: None, clipboard_source: None,
@@ -407,6 +432,11 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
.map_err( |e| RunError::EventLoop( format!( "poll timer insert_source: {e:?}" ) ) )?; .map_err( |e| RunError::EventLoop( format!( "poll timer insert_source: {e:?}" ) ) )?;
} }
if data.session.store.is_some()
{
super::session::install_save_timer( &event_loop.handle() )?;
}
while !data.exit_requested while !data.exit_requested
{ {
// Sleep until something interesting fires: // Sleep until something interesting fires:
@@ -702,7 +732,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
offset_y: *oy, offset_y: *oy,
} ); } );
} }
let app_name = "ltk-app"; let app_name = data.app.app_id();
a.update( || crate::a11y::tree::build_tree( &surfaces, kb_focus_id, app_name ) ); a.update( || crate::a11y::tree::build_tree( &surfaces, kb_focus_id, app_name ) );
} }
data.a11y = a11y_taken; data.a11y = a11y_taken;
@@ -912,5 +942,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
} }
} }
data.session.on_exit( &data.app );
Ok( () ) Ok( () )
} }

226
src/event_loop/session.rs Normal file
View 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>,
)
{
}
}

View File

@@ -42,6 +42,12 @@
//! { //! {
//! type Message = Msg; //! type Message = Msg;
//! //!
//! fn app_id( &self ) -> &str { "net.example.Hello" }
//!
//! // Nothing worth restoring in a one-button app.
//! fn save_state( &self ) -> Option<Vec<u8>> { None }
//! fn restore_state( &mut self, _state: Vec<u8> ) {}
//!
//! fn view( &self ) -> Element<Msg> //! fn view( &self ) -> Element<Msg>
//! { //! {
//! column() //! column()
@@ -293,6 +299,8 @@ pub( crate ) mod tree;
pub( crate ) mod draw; pub( crate ) mod draw;
pub( crate ) mod input; pub( crate ) mod input;
pub( crate ) mod event_loop; pub( crate ) mod event_loop;
pub( crate ) mod protocol;
pub( crate ) mod session_state;
pub( crate ) mod secure_mem; pub( crate ) mod secure_mem;
pub mod gles_render; pub mod gles_render;
pub mod egl_context; pub mod egl_context;

8
src/protocol/mod.rs Normal file
View File

@@ -0,0 +1,8 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Client bindings for Wayland protocols that `wayland-protocols` does not
//! ship generated code for yet. Each submodule vendors its XML under
//! `protocols/` and runs `wayland-scanner` at compile time.
pub( crate ) mod xdg_session_management_v1;

View File

@@ -0,0 +1,25 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! `xdg-session-management-v1` (staging). Mirrors the `wayland_protocol!`
//! macro of `wayland-protocols`; the crate names the generated code expects
//! are satisfied through sctk's reexports so ltk stays on the exact crate
//! instances sctk links.
#![ allow( dead_code, non_camel_case_types, unused_unsafe, unused_variables ) ]
#![ allow( non_upper_case_globals, non_snake_case, unused_imports ) ]
#![ allow( missing_docs, clippy::all ) ]
use smithay_client_toolkit::reexports::client as wayland_client;
use smithay_client_toolkit::reexports::protocols::xdg::shell::client::*;
use wayland_client::protocol::*;
pub mod __interfaces
{
use smithay_client_toolkit::reexports::client::backend as wayland_backend;
use smithay_client_toolkit::reexports::client::protocol::__interfaces::*;
use smithay_client_toolkit::reexports::protocols::xdg::shell::client::__interfaces::*;
wayland_scanner::generate_interfaces!( "protocols/xdg-session-management-v1.xml" );
}
use self::__interfaces::*;
wayland_scanner::generate_client_code!( "protocols/xdg-session-management-v1.xml" );

View File

@@ -901,6 +901,7 @@ impl Canvas
mod viewport_tests mod viewport_tests
{ {
use super::Canvas; use super::Canvas;
use crate::Length;
#[ test ] #[ test ]
fn viewport_logical_at_scale_one_matches_physical() fn viewport_logical_at_scale_one_matches_physical()

360
src/session_state.rs Normal file
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" );
}
}

View File

@@ -69,6 +69,9 @@
//! impl App for AppState //! impl App for AppState
//! { //! {
//! type Message = Msg; //! type Message = Msg;
//! # fn app_id( &self ) -> &str { "net.example.Combo" }
//! # fn save_state( &self ) -> Option<Vec<u8>> { None }
//! # fn restore_state( &mut self, _: Vec<u8> ) {}
//! fn view( &self ) -> Element<Msg> //! fn view( &self ) -> Element<Msg>
//! { //! {
//! let combo = self.build_combo(); //! let combo = self.build_combo();

View File

@@ -67,6 +67,10 @@ impl App for Animator
{ {
type Message = Msg; type Message = Msg;
fn app_id( &self ) -> &str { "net.liberux.ltk.test.animator" }
fn save_state( &self ) -> Option<Vec<u8>> { None }
fn restore_state( &mut self, _state: Vec<u8> ) {}
fn view( &self ) -> Element<Msg> fn view( &self ) -> Element<Msg>
{ {
column::<Msg>() column::<Msg>()

View File

@@ -42,6 +42,21 @@ impl App for Counter
{ {
type Message = Msg; type Message = Msg;
fn app_id( &self ) -> &str { "net.liberux.ltk.test.counter" }
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 ) = std::str::from_utf8( &state ).ok().and_then( |s| s.parse().ok() )
{
self.value = v;
}
}
fn view( &self ) -> Element<Msg> fn view( &self ) -> Element<Msg>
{ {
column::<Msg>() column::<Msg>()
@@ -88,6 +103,33 @@ fn render( surface: &mut UiSurface<Msg>, app: &Counter ) -> ltk::core::RenderOut
) )
} }
// ── save_state → restore_state ────────────────────────────────────────────────
#[ test ]
fn save_restore_round_trip()
{
let mut surface = UiSurface::<Msg>::new( 320, 240 );
let mut app = Counter::new();
for _ in 0..3 { app.update( Msg::Inc ); }
let bytes = app.save_state().expect( "counter persists its value" );
let mut restored = Counter::new();
assert_eq!( restored.value, 0 );
restored.restore_state( bytes );
assert_eq!( restored.value, app.value );
// The restored app renders the same shape as the original.
let _ = render( &mut surface, &app );
let n = surface.widget_rects().len();
let _ = render( &mut surface, &restored );
assert_eq!( surface.widget_rects().len(), n );
// Garbage never panics and leaves the defaults alone.
let mut fresh = Counter::new();
fresh.restore_state( vec![ 0xff, 0xfe ] );
assert_eq!( fresh.value, 0 );
}
// ── Msg → update → re-render ────────────────────────────────────────────────── // ── Msg → update → re-render ──────────────────────────────────────────────────
#[ test ] #[ test ]