Session management: xdg-session-management-v1 client, mandatory App::app_id / save_state / restore_state, runtime-managed state persistence and clean exit on signals (0.3.0)
Applications built on ltk had no way to come back where the user left them: the toolkit hardcoded `app_id = "ltk"` on every toplevel, never wrote anything to disk, and died on SIGTERM without a chance to save. This release gives the runtime the whole plumbing and asks each application only for the bytes worth keeping, in the spirit of Android's saved-instance state. The `App` trait gains three mandatory methods, deliberately without default bodies so every application states its position: `app_id()` (reverse-DNS, used for `xdg_toplevel.set_app_id`, the AccessKit application name and the state directory — the `app_id` element of the deprecated `window_config` tuple is now ignored and a one-time warning reports a mismatch), `save_state() -> Option<Vec<u8>>` and `restore_state(Vec<u8>)`. The bytes are opaque; the trait carries no serde bound. Their rustdoc is the contract: when the runtime saves, where the files live, when the bytes come back and when they do not, what must never go in them, and a worked serde_json example. The runtime persists under `$XDG_STATE_HOME/<app_id>/` (falling back to `~/.local/state`): `session.json` holds the compositor session id, a clean-exit marker and the writer's pid; `state.bin` holds the application bytes. Writes are atomic (temp file + rename, mode 0600, directory 0700) and best-effort. State is saved every 30 s when the bytes changed, once after the event loop exits (which covers `on_close_requested`, `requested_exit` and lost connections), and on SIGTERM/SIGINT — a calloop signal source, installed before any thread exists, now turns those into a clean exit of the loop instead of process death. `restore_state` runs synchronously in `try_run` before the window is created and before the first `view()`, and only when the process is relaunched as part of a session restore (`LTK_SESSION_RESTORE=1`, removed from the environment before the app can spawn children) or when the previous run left `clean_exit: false`; a plain launch starts fresh. A second concurrent instance detects the live pid and runs with persistence disabled rather than clobbering the first. The compositor side of geometry restore goes through `xdg-session-management-v1`. Neither wayland-protocols nor sctk ship generated code for it yet, so the XML is vendored under `protocols/` and `wayland-scanner` generates the client module in-tree (`src/protocol/`), resolving the crate names through sctk's reexports so the bindings stay on the crate instances sctk links. Before the first commit of a `ShellMode::Window` toplevel the runtime binds `xdg_session_manager_v1`, calls `get_session(reason, stored_id)` and `restore_toplevel(toplevel, "main")`; the three window-creation paths in `run.rs` are folded into one `make_window` helper so the attach always sits immediately before `commit()`. `created` persists the id, `replaced` destroys the objects and stops persisting. Compositors without the global lose only the geometry half. Layer-shell and session-lock surfaces skip the whole machinery. Every `App` implementor in the tree is updated: the twelve examples (`showcase`, `scroll` and `mini_shell` persist real state; the rest return `None`), both integration tests (`event_loop_flow` gains `save_restore_round_trip`), the in-source and markdown doctests, README, onboarding, cookbook (new recipe "Surviving relaunch: session state") and architecture docs, and the changelog. `src/session_state.rs` carries unit tests over a temporary state directory. `Makefile install` now copies `protocols/` into the cargo registry — without it downstream builds would fail inside the proc-macro — and `debian/copyright` covers the vendored XML. The trait change is breaking, hence 0.3.0. Also fixes the pre-existing `viewport_tests` module in `render/mod.rs`, which used `Length` without importing it and broke `cargo test`.
This commit is contained in:
@@ -6,6 +6,10 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
|
||||
|
||||
### 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.
|
||||
- **`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.
|
||||
@@ -32,6 +36,7 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
|
||||
|
||||
### 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.
|
||||
- **`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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ltk"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
rust-version = "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_json = "1.0"
|
||||
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"
|
||||
tiny-skia = "0.12"
|
||||
fontdue = "=0.9.3"
|
||||
@@ -48,6 +48,7 @@ rust-i18n = "3"
|
||||
ignore = "=0.4.23"
|
||||
wayland-protocols = { version = "0.32", features = ["client", "unstable", "staging"] }
|
||||
wayland-egl = "0.32"
|
||||
wayland-scanner = "0.31"
|
||||
khronos-egl = { version = "6", features = ["dynamic"] }
|
||||
glow = "0.17"
|
||||
raw-window-handle = "0.6"
|
||||
|
||||
2
Makefile
2
Makefile
@@ -39,7 +39,7 @@ doc:
|
||||
|
||||
install: doc
|
||||
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
|
||||
install -d $(DOCDIR)
|
||||
cp -r target/doc/* $(DOCDIR)/
|
||||
|
||||
@@ -109,6 +109,10 @@ impl App for CounterApp
|
||||
{
|
||||
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>
|
||||
{
|
||||
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
|
||||
|
||||
`ltk` currently assumes:
|
||||
|
||||
7
debian/changelog
vendored
7
debian/changelog
vendored
@@ -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
|
||||
|
||||
* 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
29
debian/copyright
vendored
@@ -11,6 +11,15 @@ Files: debian/*
|
||||
Copyright: 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
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/*
|
||||
themes/default/icons/apps/*
|
||||
themes/default/icons/app-default.svg
|
||||
@@ -254,3 +263,23 @@ License: LGPL-3
|
||||
The Adwaita cursors are alternatively available under the GNU Lesser
|
||||
General Public License version 3. On Debian systems the complete text
|
||||
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.
|
||||
|
||||
@@ -33,12 +33,14 @@ Where things live under `src/`, one line each:
|
||||
- `core.rs` — `UiSurface`, runtime-free embedding.
|
||||
- `draw/` — the per-frame drawing pipeline shared by both backends.
|
||||
- `egl_context.rs` — EGL bootstrap for the GPU path.
|
||||
- `event_loop/` — the Wayland run loop: frame scheduling, invalidation, clipboard / data device, text editing and IME, tooltips, focus, subsurfaces, perf guardrails.
|
||||
- `event_loop/` — the Wayland run loop: frame scheduling, invalidation, clipboard / data device, text editing and IME, tooltips, focus, subsurfaces, session management, perf guardrails.
|
||||
- `gles_render/` — GPU backend (EGL + GLES2 / GLES3).
|
||||
- `input/` — pointer, keyboard and touch handling, the gesture machine, dispatch.
|
||||
- `layout/` — composable arrangers for `Element` trees.
|
||||
- `protocol/` — in-tree `wayland-scanner` bindings for protocols `wayland-protocols` does not generate yet (`xdg-session-management-v1`).
|
||||
- `render/` — software rendering surface used by every widget.
|
||||
- `secure_mem.rs` — volatile wipe of secret buffers behind `TextEdit`'s secure mode.
|
||||
- `session_state.rs` — `$XDG_STATE_HOME/<app_id>/` session id + app-state files (atomic writes, clean-exit marker).
|
||||
- `system_fonts.rs` — primary-font resolution and the per-glyph fallback chain.
|
||||
- `text_shaping.rs` — BiDi reordering and rustybuzz (HarfBuzz) shaping.
|
||||
- `theme/` — theme documents, slot stores, the embedded fallback.
|
||||
@@ -68,8 +70,10 @@ In practice, that model is easiest to adopt in three steps:
|
||||
**Always implement**
|
||||
|
||||
- `type Message` — your message enum.
|
||||
- `app_id(&self) -> &str` — reverse-DNS id shared by the toplevel `app_id`, the a11y tree and the `$XDG_STATE_HOME/<app_id>/` session directory.
|
||||
- `view(&self) -> Element<Msg>` — main surface contents.
|
||||
- `update(&mut self, msg: Msg)` — state transitions.
|
||||
- `save_state(&self) -> Option<Vec<u8>>` / `restore_state(&mut self, Vec<u8>)` — opaque bytes the runtime persists (every 30 s when changed, on close, on `SIGTERM`/`SIGINT`) and hands back before the first frame on a session restore or after an unclean exit — never on a plain launch. `None` / no-op for shell components and stateless tools.
|
||||
|
||||
**Implement when your app is multi-surface**
|
||||
|
||||
@@ -102,6 +106,7 @@ In practice, that model is easiest to adopt in three steps:
|
||||
|
||||
**Implement for window / toplevel lifecycle**
|
||||
|
||||
- `app_id()` — also the key of the compositor-side `xdg-session-management-v1` session; ltk issues `restore_toplevel` before the first commit so size/position come back on every launch.
|
||||
- `window_config()` — deprecated forced-window escape hatch; prefer `shell_mode()`.
|
||||
- `on_close_requested()` — return `false` to veto a close (compositor request, titlebar button, layer-shell closed event).
|
||||
- `on_toplevel_event(event)` — open / close notifications from `ext-foreign-toplevel-list-v1`, keyed by a stable handle id.
|
||||
@@ -109,7 +114,7 @@ In practice, that model is easiest to adopt in three steps:
|
||||
|
||||
Relatedly, `ltk::try_run( app )` is the fallible variant of `ltk::run` — it returns a `RunError` (no Wayland connection, missing protocol) instead of aborting, for apps that want a CLI fallback or a clean diagnostic.
|
||||
|
||||
The defaults for everything else are sensible enough that a minimal app overrides only the four methods in the first group.
|
||||
The defaults for everything else are sensible enough that a minimal app overrides only the items in the first group.
|
||||
|
||||
Another way to read the trait is by API layer:
|
||||
|
||||
@@ -321,6 +326,8 @@ Downstream consumers shipping into regulated environments (EN 301 549, WCAG 2.1
|
||||
|
||||
**xdg-activation-v1 — wired in.** Both directions work: a token found in `$XDG_ACTIVATION_TOKEN` at startup is used to activate the app's own window once it maps (so an external launcher can raise an ltk window with focus), and an app that spawns children requests fresh tokens through `App::take_activation_requests` and receives them via `App::on_activation_token` to place in the child's environment.
|
||||
|
||||
**xdg-session-management-v1 — wired in (client side).** Before the first commit of a `ShellMode::Window` toplevel the runtime binds `xdg_session_manager_v1`, calls `get_session( reason, stored_id )` and `restore_toplevel( toplevel, "main" )`, so the compositor restores geometry on every launch. The session id from `created` is persisted to `$XDG_STATE_HOME/<app_id>/session.json` next to `state.bin`, the app's own `App::save_state` bytes (saved every 30 s when changed, on close and on `SIGTERM`/`SIGINT`, which the runtime now turns into a clean exit). App state is handed back through `App::restore_state` only for reason `session_restore` (`LTK_SESSION_RESTORE=1` in the environment, set by the shell) or `recover` (previous run left `clean_exit: false`) — a plain launch starts fresh, Android-style. Layer-shell and session-lock surfaces have no toplevel and skip all of it. Compositors without the global lose only the geometry half; the file-based state and reason detection still work. Multi-toplevel sessions and the compositor implementation in forge are tracked separately.
|
||||
|
||||
**Fractional scale — deferred.** `wp_fractional_scale_v1` (so 125 % / 150 % outputs render natively instead of via compositor downscale) remains tracked as upcoming protocol work.
|
||||
|
||||
**Software/GLES parity gaps — see [`docs/backends.md`](./backends.md).** The software backend renders gradients as a flat fill from the first stop, skips outer and inset shadows and backdrop blur, and hard-cuts the bottom-edge fade; `oklab` gradient interpolation falls back to linear-light on both backends. The capability matrix is the canonical per-feature table and must be updated in the same patch that closes any of these gaps.
|
||||
|
||||
119
docs/cookbook.md
119
docs/cookbook.md
@@ -23,6 +23,7 @@ reference, see [`docs/widgets.md`](./widgets.md). For theme JSON, see
|
||||
- [Toast / OSD with auto-expiry](#toast--osd-with-auto-expiry)
|
||||
- [Tab navigation between widgets](#tab-navigation-between-widgets)
|
||||
- [Multi-screen app via sub-state pattern](#multi-screen-app-via-sub-state-pattern)
|
||||
- [Surviving relaunch: session state](#surviving-relaunch-session-state)
|
||||
- [Embedding ltk without `ltk::run`](#embedding-ltk-without-ltkrun)
|
||||
- [Custom CPU drawing and path clipping](#custom-cpu-drawing-and-path-clipping)
|
||||
- [Projecting an externally-laid-out view tree](#projecting-an-externally-laid-out-view-tree)
|
||||
@@ -175,6 +176,10 @@ impl App for LoginApp
|
||||
{
|
||||
type Message = Msg;
|
||||
|
||||
fn app_id( &self ) -> &str { "net.example.Login" }
|
||||
fn save_state( &self ) -> Option<Vec<u8>> { None }
|
||||
fn restore_state( &mut self, _state: Vec<u8> ) {}
|
||||
|
||||
fn view( &self ) -> Element<Msg>
|
||||
{
|
||||
column()
|
||||
@@ -418,6 +423,10 @@ impl App for LauncherApp
|
||||
{
|
||||
type Message = Msg;
|
||||
|
||||
fn app_id( &self ) -> &str { "net.example.Launcher" }
|
||||
fn save_state( &self ) -> Option<Vec<u8>> { None }
|
||||
fn restore_state( &mut self, _state: Vec<u8> ) {}
|
||||
|
||||
fn view( &self ) -> Element<Msg>
|
||||
{
|
||||
let mut grid = grid::<Msg>( 4 )
|
||||
@@ -565,6 +574,10 @@ impl App for AppState
|
||||
{
|
||||
type Message = Msg;
|
||||
|
||||
fn app_id( &self ) -> &str { "net.example.Toast" }
|
||||
fn save_state( &self ) -> Option<Vec<u8>> { None }
|
||||
fn restore_state( &mut self, _state: Vec<u8> ) {}
|
||||
|
||||
fn view( &self ) -> Element<Msg> { self.main_view() }
|
||||
|
||||
fn overlays( &self ) -> Vec<OverlaySpec<Msg>>
|
||||
@@ -665,6 +678,10 @@ impl App for LoginApp
|
||||
{
|
||||
type Message = Msg;
|
||||
|
||||
fn app_id( &self ) -> &str { "net.example.Login" }
|
||||
fn save_state( &self ) -> Option<Vec<u8>> { None }
|
||||
fn restore_state( &mut self, _state: Vec<u8> ) {}
|
||||
|
||||
fn view( &self ) -> Element<Msg>
|
||||
{
|
||||
column()
|
||||
@@ -752,6 +769,26 @@ impl App for AppState
|
||||
{
|
||||
type Message = AppMsg;
|
||||
|
||||
fn app_id( &self ) -> &str { "net.example.MultiScreen" }
|
||||
|
||||
// Persist only the routing; each screen would add its own line.
|
||||
fn save_state( &self ) -> Option<Vec<u8>>
|
||||
{
|
||||
let s = match self.current { Screen::Home => "home", Screen::Settings => "settings", Screen::About => "about" };
|
||||
Some( s.as_bytes().to_vec() )
|
||||
}
|
||||
|
||||
fn restore_state( &mut self, state: Vec<u8> )
|
||||
{
|
||||
match state.as_slice()
|
||||
{
|
||||
b"home" => self.current = Screen::Home,
|
||||
b"settings" => self.current = Screen::Settings,
|
||||
b"about" => self.current = Screen::About,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn view( &self ) -> Element<AppMsg>
|
||||
{
|
||||
let body = match self.current
|
||||
@@ -798,6 +835,88 @@ per screen.
|
||||
|
||||
---
|
||||
|
||||
## Surviving relaunch: session state
|
||||
|
||||
The runtime persists whatever `App::save_state` returns and hands it
|
||||
back through `App::restore_state` before the first frame — but only
|
||||
when the shell relaunches the app as part of a session restore
|
||||
(`LTK_SESSION_RESTORE=1` in the environment) or when the previous run
|
||||
did not exit cleanly. A plain launch starts fresh, Android-style;
|
||||
window size and position still come back on every launch through
|
||||
`xdg-session-management-v1`.
|
||||
|
||||
The bytes are yours: version them, and treat a parse failure as "keep
|
||||
the defaults". No serde needed for small state.
|
||||
|
||||
```rust,no_run
|
||||
# use ltk::{ column, text, text_edit, App, Element };
|
||||
#[derive(Clone)]
|
||||
enum Msg { Tab( usize ), Draft( String ) }
|
||||
|
||||
struct Editor { tab: usize, draft: String }
|
||||
|
||||
impl App for Editor
|
||||
{
|
||||
type Message = Msg;
|
||||
|
||||
fn app_id( &self ) -> &str { "net.example.Editor" }
|
||||
|
||||
fn save_state( &self ) -> Option<Vec<u8>>
|
||||
{
|
||||
// Line 1 is the format version; the draft goes last so it may
|
||||
// contain newlines.
|
||||
Some( format!( "1\n{}\n{}", self.tab, self.draft ).into_bytes() )
|
||||
}
|
||||
|
||||
fn restore_state( &mut self, state: Vec<u8> )
|
||||
{
|
||||
let Ok( text ) = String::from_utf8( state ) else { return };
|
||||
let mut parts = text.splitn( 3, '\n' );
|
||||
if parts.next() != Some( "1" ) { return; }
|
||||
if let Some( tab ) = parts.next().and_then( |t| t.parse().ok() ) { self.tab = tab; }
|
||||
if let Some( draft ) = parts.next() { self.draft = draft.to_string(); }
|
||||
}
|
||||
|
||||
fn view( &self ) -> Element<Msg>
|
||||
{
|
||||
column()
|
||||
.push( text( format!( "tab {}", self.tab ) ) )
|
||||
.push( text_edit( "Draft", &self.draft ).on_change( Msg::Draft ) )
|
||||
.into()
|
||||
}
|
||||
|
||||
fn update( &mut self, msg: Msg )
|
||||
{
|
||||
match msg
|
||||
{
|
||||
Msg::Tab( t ) => self.tab = t,
|
||||
Msg::Draft( d ) => self.draft = d,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The runtime saves in three situations: every ~30 s while the bytes
|
||||
differ from the last save, when the window closes, and on `SIGTERM` /
|
||||
`SIGINT` (which ltk turns into a clean exit of the event loop). Files
|
||||
land in `$XDG_STATE_HOME/<app_id>/` — `state.bin` for your bytes,
|
||||
`session.json` for the compositor session id and the clean-exit marker.
|
||||
|
||||
To try it: run the app, change something, `kill -TERM $(pidof my-app)`,
|
||||
then start it again with `LTK_SESSION_RESTORE=1 my-app` — the state is
|
||||
back. Start it without the variable and it opens fresh (the window
|
||||
still lands where you left it). `kill -KILL` instead of `-TERM` and the
|
||||
next plain launch restores too, because the marker says the last run
|
||||
never exited cleanly.
|
||||
|
||||
Shell components (layer-shell panels, lock screens) have no toplevel and
|
||||
are never persisted: return `None` and leave `restore_state` empty.
|
||||
|
||||
**See also**: `App::save_state` / `App::restore_state` rustdoc for the
|
||||
full contract, and [`docs/architecture.md`](./architecture.md#known-gaps-and-non-goals) for the protocol status.
|
||||
|
||||
---
|
||||
|
||||
## Embedding ltk without `ltk::run`
|
||||
|
||||
A compositor or embedder that already owns the Wayland connection and
|
||||
|
||||
@@ -149,6 +149,25 @@ impl App for CounterApp
|
||||
{
|
||||
type Message = Msg;
|
||||
|
||||
// Names the window for the compositor and the a11y tree, and the
|
||||
// `$XDG_STATE_HOME/<app_id>/` directory the runtime saves state in.
|
||||
fn app_id( &self ) -> &str { "net.example.Counter" }
|
||||
|
||||
// Opaque bytes ltk persists for you; handed back only on a session
|
||||
// restore or after a crash — a plain launch starts fresh.
|
||||
fn save_state( &self ) -> Option<Vec<u8>>
|
||||
{
|
||||
Some( self.value.to_string().into_bytes() )
|
||||
}
|
||||
|
||||
fn restore_state( &mut self, state: Vec<u8> )
|
||||
{
|
||||
if let Some( v ) = String::from_utf8( state ).ok().and_then( |s| s.parse().ok() )
|
||||
{
|
||||
self.value = v;
|
||||
}
|
||||
}
|
||||
|
||||
fn view( &self ) -> Element<Msg>
|
||||
{
|
||||
column::<Msg>()
|
||||
@@ -306,7 +325,7 @@ In practice, most first apps only need a small subset of the surface area.
|
||||
|
||||
Start here:
|
||||
|
||||
- `App`
|
||||
- `App` — `app_id`, `view`, `update`, `save_state` / `restore_state`
|
||||
- `Element<Msg>`
|
||||
- `button`, `text`, `text_edit`, `img_widget`
|
||||
- `column`, `row`, `stack`, `grid`, `spacer`
|
||||
@@ -418,6 +437,20 @@ Use `core::UiSurface` when you want `ltk`'s layout/drawing/hit-testing without
|
||||
|
||||
There is coverage for that path in `tests/core_surface.rs`.
|
||||
|
||||
## Session state
|
||||
|
||||
`ltk` owns the plumbing, you own the bytes. `save_state` returns whatever a
|
||||
relaunch needs (any encoding — the runtime never looks inside) and the
|
||||
runtime writes it to `$XDG_STATE_HOME/<app_id>/state.bin` every 30 s when it
|
||||
changed, when the window closes and on `SIGTERM` / `SIGINT`. `restore_state`
|
||||
gets those bytes back before the first frame — but only when the shell
|
||||
relaunches the app as part of a session restore (`LTK_SESSION_RESTORE=1`) or
|
||||
when the previous run did not exit cleanly. Opening the app from the launcher
|
||||
starts fresh; window size and position still come back on every launch
|
||||
because the compositor restores them through `xdg-session-management-v1`.
|
||||
Shell components (panels, lock screens) return `None` and no-op. See the
|
||||
cookbook recipe *Surviving relaunch: session state* for a worked example.
|
||||
|
||||
## Current assumptions and rough edges
|
||||
|
||||
This repo is usable, but a few current behaviours are worth knowing up front:
|
||||
|
||||
@@ -80,6 +80,10 @@ impl App for CarouselApp
|
||||
{
|
||||
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>
|
||||
{
|
||||
let palette = ltk::theme_palette();
|
||||
|
||||
@@ -94,6 +94,10 @@ impl App for Demo
|
||||
{
|
||||
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>
|
||||
{
|
||||
// Cells side by side in landscape, stacked in portrait; the
|
||||
|
||||
@@ -155,6 +155,10 @@ impl App for Demo
|
||||
{
|
||||
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>
|
||||
{
|
||||
self.body()
|
||||
|
||||
@@ -62,6 +62,10 @@ impl App for DialogApp
|
||||
{
|
||||
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>
|
||||
{
|
||||
let palette = ltk::theme_palette();
|
||||
|
||||
@@ -52,6 +52,10 @@ impl App for InputsApp
|
||||
{
|
||||
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>
|
||||
{
|
||||
let palette = ltk::theme_palette();
|
||||
|
||||
@@ -170,6 +170,28 @@ impl App for AppState
|
||||
{
|
||||
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>
|
||||
{
|
||||
match self.route
|
||||
|
||||
@@ -116,6 +116,10 @@ impl App for PickerApp
|
||||
{
|
||||
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>
|
||||
{
|
||||
let header = text( "ltk pickers" )
|
||||
|
||||
@@ -66,6 +66,10 @@ impl App for ResponsiveApp
|
||||
{
|
||||
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>
|
||||
{
|
||||
let palette = ltk::theme_palette();
|
||||
|
||||
@@ -53,6 +53,23 @@ impl App for ScrollApp
|
||||
{
|
||||
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>
|
||||
{
|
||||
let palette = ltk::theme_palette();
|
||||
|
||||
@@ -92,6 +92,23 @@ impl App for ShowcaseApp
|
||||
{
|
||||
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>
|
||||
{
|
||||
// Pull text colours from the active theme — the runtime
|
||||
|
||||
@@ -48,6 +48,10 @@ impl App for SlidersApp
|
||||
{
|
||||
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>
|
||||
{
|
||||
// Pill dimensions chosen so the Glass insets read at their
|
||||
|
||||
@@ -64,6 +64,11 @@ impl App for WidgetsApp
|
||||
{
|
||||
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>
|
||||
{
|
||||
// Pull text colours from the active theme so the example
|
||||
|
||||
333
protocols/xdg-session-management-v1.xml
Normal file
333
protocols/xdg-session-management-v1.xml
Normal 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>
|
||||
161
src/app.rs
161
src/app.rs
@@ -337,6 +337,161 @@ pub trait App: 'static
|
||||
/// Apply a message to the application state.
|
||||
fn update( &mut self, msg: Self::Message );
|
||||
|
||||
/// Stable application identifier, reverse-DNS style
|
||||
/// (`"net.liberux.settings"`, `"io.eydos.Calls"`), constant for the
|
||||
/// life of the process.
|
||||
///
|
||||
/// The runtime uses it in three places:
|
||||
///
|
||||
/// * `xdg_toplevel.set_app_id` on the main window, so the compositor,
|
||||
/// the dock and the task switcher can match the window to its
|
||||
/// `.desktop` entry (use the desktop file's name minus `.desktop`,
|
||||
/// or its `StartupWMClass`);
|
||||
/// * the accessibility tree — AccessKit / AT-SPI2 report it as the
|
||||
/// application name;
|
||||
/// * the on-disk session directory `$XDG_STATE_HOME/<app_id>/`
|
||||
/// where [`save_state`](Self::save_state) is persisted.
|
||||
///
|
||||
/// It must not be empty and must not contain `/`; the runtime then
|
||||
/// disables session persistence and logs once. Layer-shell and
|
||||
/// session-lock components still return one (it names them in the
|
||||
/// a11y tree), even though they never get a toplevel. The `app_id`
|
||||
/// element of the deprecated [`window_config`](Self::window_config)
|
||||
/// tuple is ignored in favour of this method — keep both pointing at
|
||||
/// the same `const`.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use ltk::{ App, Element, text };
|
||||
/// # #[ derive( Clone ) ] enum Msg {}
|
||||
/// # struct Notes;
|
||||
/// const APP_ID: &str = "net.example.Notes";
|
||||
///
|
||||
/// impl App for Notes
|
||||
/// {
|
||||
/// # type Message = Msg;
|
||||
/// # fn view( &self ) -> Element<Msg> { text( "" ).into() }
|
||||
/// # fn update( &mut self, _: Msg ) {}
|
||||
/// # fn save_state( &self ) -> Option<Vec<u8>> { None }
|
||||
/// # fn restore_state( &mut self, _: Vec<u8> ) {}
|
||||
/// fn app_id( &self ) -> &str { APP_ID }
|
||||
/// }
|
||||
/// ```
|
||||
fn app_id( &self ) -> &str;
|
||||
|
||||
/// Serialize the part of the state worth surviving a restart.
|
||||
///
|
||||
/// Return the bytes the runtime should persist, or `None` when there
|
||||
/// is nothing to bring back (a shell panel, a lock screen, a
|
||||
/// stateless tool). The encoding is the app's own — JSON, CBOR, a
|
||||
/// hand-rolled line format — ltk never inspects it and puts no serde
|
||||
/// bound on the trait. Keep it small and cheap: the runtime calls
|
||||
/// this
|
||||
///
|
||||
/// * every ~30 s while the app runs, touching the disk only when
|
||||
/// the bytes differ from the last save (a UI-only change that
|
||||
/// round-trips to identical bytes costs one comparison);
|
||||
/// * when the window closes — after
|
||||
/// [`on_close_requested`](Self::on_close_requested) allowed it or
|
||||
/// [`requested_exit`](Self::requested_exit) returned `true`;
|
||||
/// * on `SIGTERM` / `SIGINT`, which the runtime turns into a clean
|
||||
/// exit of the event loop instead of letting the process die.
|
||||
///
|
||||
/// The bytes are written atomically (temp file + rename, mode
|
||||
/// `0600`) to `$XDG_STATE_HOME/<app_id>/state.bin`
|
||||
/// (`~/.local/state/<app_id>/state.bin` when the variable is unset)
|
||||
/// next to a `session.json` holding the compositor's
|
||||
/// `xdg-session-management-v1` session id and a clean-exit marker.
|
||||
/// Returning `None` removes a stale `state.bin`. Only
|
||||
/// [`ShellMode::Window`] apps (and the deprecated
|
||||
/// [`window_config`](Self::window_config) path) are persisted;
|
||||
/// layer and session-lock surfaces are skipped silently, so shell
|
||||
/// components simply return `None`.
|
||||
///
|
||||
/// The file is plain on disk: never put secrets in it (passwords
|
||||
/// belong to the secure-mode `text_edit` and its wiped buffers),
|
||||
/// and keep large blobs (images, caches) elsewhere. The signal
|
||||
/// handler only covers threads started after [`run`](crate::run)
|
||||
/// was entered; a thread the app spawned earlier that receives
|
||||
/// `SIGTERM` still terminates the process — block the signal in
|
||||
/// such threads or start them from inside the app.
|
||||
///
|
||||
/// [`restore_state`](Self::restore_state) explains when — and when
|
||||
/// not — the bytes come back.
|
||||
///
|
||||
/// ```rust
|
||||
/// use ltk::{ App, Element, text };
|
||||
/// use serde::{ Deserialize, Serialize };
|
||||
///
|
||||
/// #[ derive( Clone ) ]
|
||||
/// enum Msg { Tab( usize ) }
|
||||
///
|
||||
/// #[ derive( Serialize, Deserialize ) ]
|
||||
/// struct Persisted { version: u32, tab: usize, draft: String }
|
||||
///
|
||||
/// struct Editor { tab: usize, draft: String, cursor_blink: bool }
|
||||
///
|
||||
/// impl App for Editor
|
||||
/// {
|
||||
/// type Message = Msg;
|
||||
///
|
||||
/// fn app_id( &self ) -> &str { "net.example.Editor" }
|
||||
///
|
||||
/// fn save_state( &self ) -> Option<Vec<u8>>
|
||||
/// {
|
||||
/// // Only what a relaunch needs — the blink phase stays out.
|
||||
/// let p = Persisted { version: 1, tab: self.tab, draft: self.draft.clone() };
|
||||
/// serde_json::to_vec( &p ).ok()
|
||||
/// }
|
||||
///
|
||||
/// fn restore_state( &mut self, state: Vec<u8> )
|
||||
/// {
|
||||
/// // Tolerate an older or corrupt file: keep the defaults.
|
||||
/// if let Ok( p ) = serde_json::from_slice::<Persisted>( &state )
|
||||
/// {
|
||||
/// self.tab = p.tab;
|
||||
/// self.draft = p.draft;
|
||||
/// }
|
||||
/// }
|
||||
/// # fn view( &self ) -> Element<Msg> { text( self.draft.clone() ).into() }
|
||||
/// # fn update( &mut self, msg: Msg ) { let Msg::Tab( t ) = msg; self.tab = t; }
|
||||
/// }
|
||||
/// ```
|
||||
fn save_state( &self ) -> Option<Vec<u8>>;
|
||||
|
||||
/// Re-apply bytes previously returned by [`save_state`](Self::save_state).
|
||||
///
|
||||
/// Called at most once per process, synchronously inside
|
||||
/// [`run`](crate::run) / [`try_run`](crate::try_run), before the
|
||||
/// window is created and before the first [`view`](Self::view) — the
|
||||
/// first frame already shows the restored state. It is **not**
|
||||
/// called on an ordinary launch. The runtime restores app state only
|
||||
/// when
|
||||
///
|
||||
/// * the process is relaunched as part of a session restore: the
|
||||
/// shell sets `LTK_SESSION_RESTORE=1` in the environment (the
|
||||
/// runtime removes the variable before the app can spawn
|
||||
/// children); or
|
||||
/// * the previous run of this `app_id` did not exit cleanly (crash,
|
||||
/// `SIGKILL`, power loss): its `session.json` still says
|
||||
/// `clean_exit: false`.
|
||||
///
|
||||
/// This mirrors Android's saved-instance-state contract: opening the
|
||||
/// app from the launcher gives a fresh instance, coming back after
|
||||
/// the system killed it lands where the user left off. Window
|
||||
/// geometry (size, position, workspace) is separate — the compositor
|
||||
/// restores it through `xdg-session-management-v1` on *every*
|
||||
/// launch, plain ones included, using the session id ltk stores.
|
||||
///
|
||||
/// `state` is exactly what `save_state` produced, possibly by an
|
||||
/// older build: validate, version, and fall back to defaults instead
|
||||
/// of panicking. Nothing is called when no state file exists or the
|
||||
/// app returned `None` last time. Shell components never receive
|
||||
/// this call and implement it as a no-op.
|
||||
///
|
||||
/// See [`save_state`](Self::save_state) for the file layout and a
|
||||
/// worked example.
|
||||
fn restore_state( &mut self, state: Vec<u8> );
|
||||
|
||||
/// Tell the runtime which surfaces *could* have changed visibly as a
|
||||
/// result of [`update`](Self::update) being called with this message.
|
||||
///
|
||||
@@ -777,7 +932,8 @@ pub trait App: 'static
|
||||
/// Return `Some(( title, app_id ))` to force an XDG toplevel window instead of
|
||||
/// layer-shell overlay. The compositor will display the title in the title bar
|
||||
/// and use the app_id for taskbar/icon matching. Return `None` (default) to
|
||||
/// use layer-shell when available.
|
||||
/// use layer-shell when available. The `app_id` element is ignored:
|
||||
/// [`app_id`](Self::app_id) names the window.
|
||||
///
|
||||
/// **Deprecated**: Use [`shell_mode`](Self::shell_mode) instead.
|
||||
fn window_config( &self ) -> Option<( &str, &str )> { None }
|
||||
@@ -961,6 +1117,9 @@ pub fn run<A: App>( app: A )
|
||||
/// # struct MyApp;
|
||||
/// # impl App for MyApp {
|
||||
/// # type Message = Msg;
|
||||
/// # fn app_id( &self ) -> &str { "net.example.MyApp" }
|
||||
/// # fn save_state( &self ) -> Option<Vec<u8>> { None }
|
||||
/// # fn restore_state( &mut self, _: Vec<u8> ) {}
|
||||
/// # fn view( &self ) -> Element<Msg> { button( "x" ).into() }
|
||||
/// # fn update( &mut self, _: Msg ) {}
|
||||
/// # }
|
||||
|
||||
@@ -103,6 +103,11 @@ pub struct AppData<A: App>
|
||||
/// (resizes, scale changes) must not re-activate.
|
||||
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
|
||||
/// does not advertise the global, in which case copy / paste stays
|
||||
/// process-local and inbound selections from other clients are
|
||||
|
||||
@@ -11,6 +11,7 @@ pub( crate ) mod focus;
|
||||
mod handlers;
|
||||
pub( crate ) mod perf;
|
||||
pub( crate ) mod repeat;
|
||||
pub( crate ) mod session;
|
||||
pub( crate ) mod subsurface;
|
||||
pub( crate ) mod surface;
|
||||
pub( crate ) mod text_editing;
|
||||
|
||||
@@ -51,7 +51,7 @@ pub( crate ) fn run<A: App>( app: A )
|
||||
/// The dispatch loop's runtime errors still panic — they are non-
|
||||
/// recoverable once the surface is on screen, and the surface state
|
||||
/// 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()
|
||||
.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() )
|
||||
.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 )
|
||||
.map_err( |e| RunError::MissingProtocol { name: "wl_compositor", detail: format!( "{e:?}" ) } )?;
|
||||
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()
|
||||
.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>>|
|
||||
-> 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 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() );
|
||||
apply_size_hint( &window );
|
||||
apply_fullscreen( &window );
|
||||
if attach
|
||||
{
|
||||
session.attach_toplevel( &window, &qh );
|
||||
}
|
||||
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 ) )
|
||||
} else {
|
||||
// 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 )
|
||||
}
|
||||
ShellMode::Window => {
|
||||
let xdg = bind_xdg( &globals, &qh )?;
|
||||
let surface = compositor.create_surface( &qh );
|
||||
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
|
||||
window.set_title( "ltk" );
|
||||
window.set_app_id( "ltk" );
|
||||
apply_size_hint( &window );
|
||||
window.commit();
|
||||
let xdg = bind_xdg( &globals, &qh )?;
|
||||
let window = make_window( &xdg, "ltk", &mut session, true );
|
||||
( SurfaceKind::Window( window ), Some( xdg ) )
|
||||
}
|
||||
ShellMode::Layer( layer ) => {
|
||||
@@ -180,13 +211,8 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
||||
( SurfaceKind::Pending( cfg ), None )
|
||||
} else {
|
||||
eprintln!( "ltk: wlr-layer-shell not available, falling back to xdg window" );
|
||||
let xdg = bind_xdg( &globals, &qh )?;
|
||||
let surface = compositor.create_surface( &qh );
|
||||
let window = xdg.create_window( surface, WindowDecorations::RequestServer, &qh );
|
||||
window.set_title( "ltk" );
|
||||
window.set_app_id( "ltk" );
|
||||
apply_size_hint( &window );
|
||||
window.commit();
|
||||
let xdg = bind_xdg( &globals, &qh )?;
|
||||
let window = make_window( &xdg, "ltk", &mut session, false );
|
||||
( 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,
|
||||
// headless CI, etc.) — the runtime then runs with no
|
||||
// accessibility tree, which is the previous behaviour.
|
||||
let a11y_app_name = "ltk-app";
|
||||
let a11y_app_id = "net.liberux.ltk";
|
||||
let a11y = crate::a11y::A11yState::try_new( a11y_app_name, a11y_app_id );
|
||||
let a11y = crate::a11y::A11yState::try_new( &app_id, &app_id );
|
||||
// xdg-activation-v1: optional. Compositors that don't carry the
|
||||
// global leave `activation_state` as `None` and the inbound /
|
||||
// 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,
|
||||
activation_state,
|
||||
activation_token_pending,
|
||||
session,
|
||||
data_device_manager,
|
||||
data_device: 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:?}" ) ) )?;
|
||||
}
|
||||
|
||||
if data.session.store.is_some()
|
||||
{
|
||||
super::session::install_save_timer( &event_loop.handle() )?;
|
||||
}
|
||||
|
||||
while !data.exit_requested
|
||||
{
|
||||
// Sleep until something interesting fires:
|
||||
@@ -702,7 +732,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
|
||||
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 ) );
|
||||
}
|
||||
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( () )
|
||||
}
|
||||
|
||||
226
src/event_loop/session.rs
Normal file
226
src/event_loop/session.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-only
|
||||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||
|
||||
//! `xdg-session-management-v1` client glue plus the runtime hooks that
|
||||
//! persist `App::save_state` and turn signals into a clean exit.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use calloop::timer::{ TimeoutAction, Timer };
|
||||
use calloop::LoopHandle;
|
||||
use smithay_client_toolkit::reexports::client::globals::GlobalList;
|
||||
use smithay_client_toolkit::reexports::client::{ Connection, Dispatch, QueueHandle };
|
||||
use smithay_client_toolkit::shell::xdg::window::Window;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::protocol::xdg_session_management_v1::
|
||||
{
|
||||
xdg_session_manager_v1::{ self, XdgSessionManagerV1 },
|
||||
xdg_session_v1::{ self, XdgSessionV1 },
|
||||
xdg_toplevel_session_v1::{ self, XdgToplevelSessionV1 },
|
||||
};
|
||||
use crate::session_state::{ RestoreReason, Startup, StateStore };
|
||||
|
||||
use super::error::RunError;
|
||||
use super::AppData;
|
||||
|
||||
pub( crate ) const TOPLEVEL_NAME: &str = "main";
|
||||
pub( crate ) const SAVE_INTERVAL: Duration = Duration::from_secs( 30 );
|
||||
pub( crate ) const RESTORE_ENV: &str = "LTK_SESSION_RESTORE";
|
||||
|
||||
pub( crate ) struct SessionRuntime
|
||||
{
|
||||
pub session: Option<XdgSessionV1>,
|
||||
pub toplevel_session: Option<XdgToplevelSessionV1>,
|
||||
/// `None` when persistence is off: layer / lock surfaces, an unusable
|
||||
/// app_id, a concurrent instance, or after `replaced`.
|
||||
pub store: Option<StateStore>,
|
||||
pub reason: RestoreReason,
|
||||
pub replaced: bool,
|
||||
}
|
||||
|
||||
impl SessionRuntime
|
||||
{
|
||||
pub fn disabled() -> Self
|
||||
{
|
||||
Self {
|
||||
session: None,
|
||||
toplevel_session: None,
|
||||
store: None,
|
||||
reason: RestoreReason::Launch,
|
||||
replaced: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 1, before any window exists: read the restore hint from the
|
||||
/// environment, decide the reason, hand saved bytes to the app and mark
|
||||
/// the run as live.
|
||||
pub fn bootstrap<A: App>( app: &mut A ) -> Self
|
||||
{
|
||||
let env_restore = std::env::var_os( RESTORE_ENV ).is_some_and( |v| v == "1" );
|
||||
if std::env::var_os( RESTORE_ENV ).is_some()
|
||||
{
|
||||
// SAFETY: removing an env var is sound only when no other thread
|
||||
// is reading the environment concurrently. We are still in the
|
||||
// init phase before `set_channel_sender`, so the app has had
|
||||
// no opportunity to spawn worker threads yet.
|
||||
unsafe { std::env::remove_var( RESTORE_ENV ); }
|
||||
}
|
||||
|
||||
let mut rt = Self::disabled();
|
||||
let Some( mut store ) = StateStore::open( app.app_id() ) else { return rt };
|
||||
match store.decide( env_restore )
|
||||
{
|
||||
Startup::Concurrent =>
|
||||
{
|
||||
eprintln!( "ltk: another instance of {} is running; session persistence disabled", app.app_id() );
|
||||
return rt;
|
||||
}
|
||||
Startup::Reason( reason ) => rt.reason = reason,
|
||||
}
|
||||
if rt.reason != RestoreReason::Launch
|
||||
{
|
||||
if let Some( bytes ) = store.load_state()
|
||||
{
|
||||
app.restore_state( bytes );
|
||||
}
|
||||
}
|
||||
store.mark_running();
|
||||
rt.store = Some( store );
|
||||
rt
|
||||
}
|
||||
|
||||
/// Phase 2: bind the manager and open the session. Silent when the
|
||||
/// compositor lacks the global. The manager proxy is not kept: it has
|
||||
/// no state of its own and the session outlives it server-side.
|
||||
pub fn bind<A: App>( &mut self, globals: &GlobalList, qh: &QueueHandle<AppData<A>> )
|
||||
{
|
||||
let Some( store ) = &self.store else { return };
|
||||
let manager: Option<XdgSessionManagerV1> = globals.bind( qh, 1..=1, () ).ok();
|
||||
let Some( manager ) = manager else { return };
|
||||
let reason = match self.reason
|
||||
{
|
||||
RestoreReason::Launch => xdg_session_manager_v1::Reason::Launch,
|
||||
RestoreReason::Recover => xdg_session_manager_v1::Reason::Recover,
|
||||
RestoreReason::SessionRestore => xdg_session_manager_v1::Reason::SessionRestore,
|
||||
};
|
||||
self.session = Some( manager.get_session( reason, store.session_id(), qh, () ) );
|
||||
}
|
||||
|
||||
/// Phase 3: register the main toplevel. Must run before the window's
|
||||
/// first commit or the compositor raises `already_mapped`.
|
||||
pub fn attach_toplevel<A: App>( &mut self, window: &Window, qh: &QueueHandle<AppData<A>> )
|
||||
{
|
||||
let Some( session ) = &self.session else { return };
|
||||
self.toplevel_session =
|
||||
Some( session.restore_toplevel( window.xdg_toplevel(), TOPLEVEL_NAME.to_string(), qh, () ) );
|
||||
}
|
||||
|
||||
pub fn periodic_save<A: App>( &mut self, app: &A )
|
||||
{
|
||||
if self.replaced { return; }
|
||||
if let Some( store ) = &mut self.store
|
||||
{
|
||||
store.save_state_if_changed( app.save_state() );
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_exit<A: App>( &mut self, app: &A )
|
||||
{
|
||||
if self.replaced { return; }
|
||||
if let Some( store ) = &mut self.store
|
||||
{
|
||||
store.mark_clean_exit( app.save_state() );
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_replaced( &mut self )
|
||||
{
|
||||
eprintln!( "ltk: session taken over by another instance; this one stops persisting state" );
|
||||
if let Some( t ) = self.toplevel_session.take() { t.destroy(); }
|
||||
if let Some( s ) = self.session.take() { s.destroy(); }
|
||||
self.replaced = true;
|
||||
self.store = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub( crate ) fn install_signal_source<A: App>( handle: &LoopHandle<'static, AppData<A>> ) -> Result<(), RunError>
|
||||
{
|
||||
use calloop::signals::{ Signal, Signals };
|
||||
let signals = Signals::new( &[ Signal::SIGTERM, Signal::SIGINT ] )
|
||||
.map_err( |e| RunError::EventLoop( format!( "Signals::new: {e}" ) ) )?;
|
||||
handle
|
||||
.insert_source( signals, |event, _, data: &mut AppData<A>|
|
||||
{
|
||||
eprintln!( "ltk: {:?} received, exiting cleanly", event.signal() );
|
||||
data.exit_requested = true;
|
||||
} )
|
||||
.map_err( |e| RunError::EventLoop( format!( "signals insert_source: {e:?}" ) ) )?;
|
||||
Ok( () )
|
||||
}
|
||||
|
||||
pub( crate ) fn install_save_timer<A: App>( handle: &LoopHandle<'static, AppData<A>> ) -> Result<(), RunError>
|
||||
{
|
||||
handle
|
||||
.insert_source( Timer::from_duration( SAVE_INTERVAL ), |_, _, data: &mut AppData<A>|
|
||||
{
|
||||
data.session.periodic_save( &data.app );
|
||||
TimeoutAction::ToDuration( SAVE_INTERVAL )
|
||||
} )
|
||||
.map_err( |e| RunError::EventLoop( format!( "save timer insert_source: {e:?}" ) ) )?;
|
||||
Ok( () )
|
||||
}
|
||||
|
||||
impl<A: App> Dispatch<XdgSessionManagerV1, ()> for AppData<A>
|
||||
{
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &XdgSessionManagerV1,
|
||||
_event: xdg_session_manager_v1::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> Dispatch<XdgSessionV1, ()> for AppData<A>
|
||||
{
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_proxy: &XdgSessionV1,
|
||||
event: xdg_session_v1::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
)
|
||||
{
|
||||
match event
|
||||
{
|
||||
xdg_session_v1::Event::Created { session_id } =>
|
||||
{
|
||||
if let Some( store ) = &mut state.session.store
|
||||
{
|
||||
store.set_session_id( session_id );
|
||||
}
|
||||
}
|
||||
xdg_session_v1::Event::Restored => {}
|
||||
xdg_session_v1::Event::Replaced => state.session.on_replaced(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: App> Dispatch<XdgToplevelSessionV1, ()> for AppData<A>
|
||||
{
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_proxy: &XdgToplevelSessionV1,
|
||||
_event: xdg_toplevel_session_v1::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,12 @@
|
||||
//! {
|
||||
//! 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>
|
||||
//! {
|
||||
//! column()
|
||||
@@ -293,6 +299,8 @@ pub( crate ) mod tree;
|
||||
pub( crate ) mod draw;
|
||||
pub( crate ) mod input;
|
||||
pub( crate ) mod event_loop;
|
||||
pub( crate ) mod protocol;
|
||||
pub( crate ) mod session_state;
|
||||
pub( crate ) mod secure_mem;
|
||||
pub mod gles_render;
|
||||
pub mod egl_context;
|
||||
|
||||
8
src/protocol/mod.rs
Normal file
8
src/protocol/mod.rs
Normal 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;
|
||||
25
src/protocol/xdg_session_management_v1.rs
Normal file
25
src/protocol/xdg_session_management_v1.rs
Normal 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" );
|
||||
@@ -901,6 +901,7 @@ impl Canvas
|
||||
mod viewport_tests
|
||||
{
|
||||
use super::Canvas;
|
||||
use crate::Length;
|
||||
|
||||
#[ test ]
|
||||
fn viewport_logical_at_scale_one_matches_physical()
|
||||
|
||||
360
src/session_state.rs
Normal file
360
src/session_state.rs
Normal 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" );
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,9 @@
|
||||
//! impl App for AppState
|
||||
//! {
|
||||
//! 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>
|
||||
//! {
|
||||
//! let combo = self.build_combo();
|
||||
|
||||
@@ -67,6 +67,10 @@ impl App for Animator
|
||||
{
|
||||
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>
|
||||
{
|
||||
column::<Msg>()
|
||||
|
||||
@@ -42,6 +42,21 @@ impl App for Counter
|
||||
{
|
||||
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>
|
||||
{
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
#[ test ]
|
||||
|
||||
Reference in New Issue
Block a user