diff --git a/CHANGELOG.md b/CHANGELOG.md index d809606..e9a4e22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//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 )` / `ListItem::font_size( impl Into )`** — 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`** 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. diff --git a/Cargo.toml b/Cargo.toml index d71912c..bb500b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/Makefile b/Makefile index 4533a10..3699991 100644 --- a/Makefile +++ b/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)/ diff --git a/README.md b/README.md index f10277c..beb4c5e 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,10 @@ impl App for CounterApp { type Message = Msg; + fn app_id( &self ) -> &str { "net.example.Counter" } + fn save_state( &self ) -> Option> { None } + fn restore_state( &mut self, _state: Vec ) {} + fn view( &self ) -> Element { column::() @@ -137,6 +141,11 @@ fn main() } ``` +`app_id` names the window and the `$XDG_STATE_HOME//` 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: diff --git a/debian/changelog b/debian/changelog index 645d380..0b302d4 100644 --- a/debian/changelog +++ b/debian/changelog @@ -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// (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 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). diff --git a/debian/copyright b/debian/copyright index 9f51475..656cf8f 100644 --- a/debian/copyright +++ b/debian/copyright @@ -11,6 +11,15 @@ Files: debian/* Copyright: 2026 Liberux Labs, S. L. 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. diff --git a/docs/architecture.md b/docs/architecture.md index 8386cd3..c630fc9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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//` 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//` session directory. - `view(&self) -> Element` — main surface contents. - `update(&mut self, msg: Msg)` — state transitions. +- `save_state(&self) -> Option>` / `restore_state(&mut self, Vec)` — 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//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. diff --git a/docs/cookbook.md b/docs/cookbook.md index 2460bda..a1c902e 100644 --- a/docs/cookbook.md +++ b/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> { None } + fn restore_state( &mut self, _state: Vec ) {} + fn view( &self ) -> Element { 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> { None } + fn restore_state( &mut self, _state: Vec ) {} + fn view( &self ) -> Element { let mut grid = grid::( 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> { None } + fn restore_state( &mut self, _state: Vec ) {} + fn view( &self ) -> Element { self.main_view() } fn overlays( &self ) -> Vec> @@ -665,6 +678,10 @@ impl App for LoginApp { type Message = Msg; + fn app_id( &self ) -> &str { "net.example.Login" } + fn save_state( &self ) -> Option> { None } + fn restore_state( &mut self, _state: Vec ) {} + fn view( &self ) -> Element { 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> + { + 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 ) + { + 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 { 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> + { + // 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 ) + { + 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 + { + 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//` — `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 diff --git a/docs/onboarding.md b/docs/onboarding.md index 474cf84..e425c51 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -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//` 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> + { + Some( self.value.to_string().into_bytes() ) + } + + fn restore_state( &mut self, state: Vec ) + { + if let Some( v ) = String::from_utf8( state ).ok().and_then( |s| s.parse().ok() ) + { + self.value = v; + } + } + fn view( &self ) -> Element { column::() @@ -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` - `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//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: diff --git a/examples/carousel.rs b/examples/carousel.rs index afdd1ea..8f8a626 100644 --- a/examples/carousel.rs +++ b/examples/carousel.rs @@ -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> { None } + fn restore_state( &mut self, _state: Vec ) {} + fn view( &self ) -> Element { let palette = ltk::theme_palette(); diff --git a/examples/clip_path.rs b/examples/clip_path.rs index 9962767..831cf21 100644 --- a/examples/clip_path.rs +++ b/examples/clip_path.rs @@ -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> { None } + fn restore_state( &mut self, _state: Vec ) {} + fn view( &self ) -> Element { // Cells side by side in landscape, stacked in portrait; the diff --git a/examples/combo.rs b/examples/combo.rs index f70ced8..449351b 100644 --- a/examples/combo.rs +++ b/examples/combo.rs @@ -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> { None } + fn restore_state( &mut self, _state: Vec ) {} + fn view( &self ) -> Element { self.body() diff --git a/examples/dialog.rs b/examples/dialog.rs index deedbbd..d4da875 100644 --- a/examples/dialog.rs +++ b/examples/dialog.rs @@ -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> { None } + fn restore_state( &mut self, _state: Vec ) {} + fn view( &self ) -> Element { let palette = ltk::theme_palette(); diff --git a/examples/inputs.rs b/examples/inputs.rs index 5946dbd..4756389 100644 --- a/examples/inputs.rs +++ b/examples/inputs.rs @@ -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> { None } + fn restore_state( &mut self, _state: Vec ) {} + fn view( &self ) -> Element { let palette = ltk::theme_palette(); diff --git a/examples/mini_shell.rs b/examples/mini_shell.rs index ee271da..17d47ef 100644 --- a/examples/mini_shell.rs +++ b/examples/mini_shell.rs @@ -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> + { + 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 ) + { + 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::().ok() ) { self.brightness = b.clamp( 0.0, 1.0 ); } + } + fn view( &self ) -> Element { match self.route diff --git a/examples/pickers.rs b/examples/pickers.rs index 45fb7bb..5cded9e 100644 --- a/examples/pickers.rs +++ b/examples/pickers.rs @@ -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> { None } + fn restore_state( &mut self, _state: Vec ) {} + fn view( &self ) -> Element { let header = text( "ltk pickers" ) diff --git a/examples/responsive.rs b/examples/responsive.rs index 1fec330..d0d3567 100644 --- a/examples/responsive.rs +++ b/examples/responsive.rs @@ -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> { None } + fn restore_state( &mut self, _state: Vec ) {} + fn view( &self ) -> Element { let palette = ltk::theme_palette(); diff --git a/examples/scroll.rs b/examples/scroll.rs index ba05457..121ec92 100644 --- a/examples/scroll.rs +++ b/examples/scroll.rs @@ -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> + { + Some( match self.mode { Mode::List => b"list".to_vec(), Mode::Grid => b"grid".to_vec() } ) + } + + fn restore_state( &mut self, state: Vec ) + { + match state.as_slice() + { + b"list" => self.mode = Mode::List, + b"grid" => self.mode = Mode::Grid, + _ => {} + } + } + fn view( &self ) -> Element { let palette = ltk::theme_palette(); diff --git a/examples/showcase.rs b/examples/showcase.rs index 478cf79..3db5504 100644 --- a/examples/showcase.rs +++ b/examples/showcase.rs @@ -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> + { + Some( format!( "1\n{}\n{}\n{}", self.tab, self.slider_value, self.note ).into_bytes() ) + } + + fn restore_state( &mut self, state: Vec ) + { + 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 { // Pull text colours from the active theme — the runtime diff --git a/examples/sliders.rs b/examples/sliders.rs index 40d2ef3..90438d6 100644 --- a/examples/sliders.rs +++ b/examples/sliders.rs @@ -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> { None } + fn restore_state( &mut self, _state: Vec ) {} + fn view( &self ) -> Element { // Pill dimensions chosen so the Glass insets read at their diff --git a/examples/widgets.rs b/examples/widgets.rs index 509ea6b..50de89f 100644 --- a/examples/widgets.rs +++ b/examples/widgets.rs @@ -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> { None } + fn restore_state( &mut self, _state: Vec ) {} + fn view( &self ) -> Element { // Pull text colours from the active theme so the example diff --git a/protocols/xdg-session-management-v1.xml b/protocols/xdg-session-management-v1.xml new file mode 100644 index 0000000..9cfe5f8 --- /dev/null +++ b/protocols/xdg-session-management-v1.xml @@ -0,0 +1,333 @@ + + + + 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. + + + + 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. + + + + + 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. + + + + + + + + + + + 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. + + + + A new app instance is launched, for example from an app launcher. + + + + + An app instance is recovering from for example a compositor or app crash. + + + + + An app instance is restored, for example part of a restored session, or + restored from having been temporarily terminated due to resource + constraints. + + + + + + + Destroy the manager object. The existing session objects will be + unaffected. + + + + + + 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. + + + + + + + + + + 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. + + + + + + + + + + + + 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. + + + + + + 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. + + + + + + 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. + + + + + + + + + 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. + + + + + + + + + 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). + + + + + + + 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. + + + + + + + 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. + + + + + + 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. + + + + + + + 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. + + + + + Destroy the object. This has no effect over window management of the + associated toplevel. + + + + + + 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. + + + + + + + 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. + + + + diff --git a/src/app.rs b/src/app.rs index e006e82..6c6ca32 100644 --- a/src/app.rs +++ b/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//` + /// 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 { text( "" ).into() } + /// # fn update( &mut self, _: Msg ) {} + /// # fn save_state( &self ) -> Option> { None } + /// # fn restore_state( &mut self, _: Vec ) {} + /// 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//state.bin` + /// (`~/.local/state//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> + /// { + /// // 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 ) + /// { + /// // Tolerate an older or corrupt file: keep the defaults. + /// if let Ok( p ) = serde_json::from_slice::( &state ) + /// { + /// self.tab = p.tab; + /// self.draft = p.draft; + /// } + /// } + /// # fn view( &self ) -> Element { text( self.draft.clone() ).into() } + /// # fn update( &mut self, msg: Msg ) { let Msg::Tab( t ) = msg; self.tab = t; } + /// } + /// ``` + fn save_state( &self ) -> Option>; + + /// 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 ); + /// 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( app: A ) /// # struct MyApp; /// # impl App for MyApp { /// # type Message = Msg; +/// # fn app_id( &self ) -> &str { "net.example.MyApp" } +/// # fn save_state( &self ) -> Option> { None } +/// # fn restore_state( &mut self, _: Vec ) {} /// # fn view( &self ) -> Element { button( "x" ).into() } /// # fn update( &mut self, _: Msg ) {} /// # } diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index 42b07ca..9fb3a01 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -103,6 +103,11 @@ pub struct AppData /// (resizes, scale changes) must not re-activate. pub activation_token_pending: Option, + /// `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 diff --git a/src/event_loop/mod.rs b/src/event_loop/mod.rs index 78ee32c..9b24e0d 100644 --- a/src/event_loop/mod.rs +++ b/src/event_loop/mod.rs @@ -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; diff --git a/src/event_loop/run.rs b/src/event_loop/run.rs index 6121bc0..a0133b5 100644 --- a/src/event_loop/run.rs +++ b/src/event_loop/run.rs @@ -51,7 +51,7 @@ pub( crate ) fn run( 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( app: A ) -> Result<(), RunError> +pub( crate ) fn try_run( 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( 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( 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>| -> Result { @@ -132,16 +156,28 @@ pub( crate ) fn try_run( 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( 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( 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( 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( 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( 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( 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( app: A ) -> Result<(), RunError> } } + data.session.on_exit( &data.app ); + Ok( () ) } diff --git a/src/event_loop/session.rs b/src/event_loop/session.rs new file mode 100644 index 0000000..71a8a78 --- /dev/null +++ b/src/event_loop/session.rs @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! `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, + pub toplevel_session: Option, + /// `None` when persistence is off: layer / lock surfaces, an unusable + /// app_id, a concurrent instance, or after `replaced`. + pub store: Option, + 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( 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( &mut self, globals: &GlobalList, qh: &QueueHandle> ) + { + let Some( store ) = &self.store else { return }; + let manager: Option = 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( &mut self, window: &Window, qh: &QueueHandle> ) + { + 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( &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( &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( handle: &LoopHandle<'static, AppData> ) -> 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| + { + 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( handle: &LoopHandle<'static, AppData> ) -> Result<(), RunError> +{ + handle + .insert_source( Timer::from_duration( SAVE_INTERVAL ), |_, _, data: &mut AppData| + { + data.session.periodic_save( &data.app ); + TimeoutAction::ToDuration( SAVE_INTERVAL ) + } ) + .map_err( |e| RunError::EventLoop( format!( "save timer insert_source: {e:?}" ) ) )?; + Ok( () ) +} + +impl Dispatch for AppData +{ + fn event( + _state: &mut Self, + _proxy: &XdgSessionManagerV1, + _event: xdg_session_manager_v1::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) + { + } +} + +impl Dispatch for AppData +{ + fn event( + state: &mut Self, + _proxy: &XdgSessionV1, + event: xdg_session_v1::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) + { + 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 Dispatch for AppData +{ + fn event( + _state: &mut Self, + _proxy: &XdgToplevelSessionV1, + _event: xdg_toplevel_session_v1::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) + { + } +} diff --git a/src/lib.rs b/src/lib.rs index f1e954c..f6a6e06 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -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> { None } +//! fn restore_state( &mut self, _state: Vec ) {} +//! //! fn view( &self ) -> Element //! { //! 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; diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs new file mode 100644 index 0000000..0564154 --- /dev/null +++ b/src/protocol/mod.rs @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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; diff --git a/src/protocol/xdg_session_management_v1.rs b/src/protocol/xdg_session_management_v1.rs new file mode 100644 index 0000000..86e7eb7 --- /dev/null +++ b/src/protocol/xdg_session_management_v1.rs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! `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" ); diff --git a/src/render/mod.rs b/src/render/mod.rs index 226bdfe..4810fb4 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -901,6 +901,7 @@ impl Canvas mod viewport_tests { use super::Canvas; + use crate::Length; #[ test ] fn viewport_logical_at_scale_one_matches_physical() diff --git a/src/session_state.rs b/src/session_state.rs new file mode 100644 index 0000000..54877f2 --- /dev/null +++ b/src/session_state.rs @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! On-disk session state: `$XDG_STATE_HOME//{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, + pub clean_exit: bool, + pub pid: u32, +} + +/// Handle on one application's state directory. +pub( crate ) struct StateStore +{ + dir: PathBuf, + session_id: Option, + last_saved: Option>, +} + +/// 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 +{ + 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 + { + 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 + { + 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 + { + let bytes = fs::read( self.dir.join( SESSION_FILE ) ).ok()?; + serde_json::from_slice::( &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 + { + self.session_id.clone() + } + + pub fn load_state( &self ) -> Option> + { + 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> ) -> 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> ) + { + 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" ); + } +} diff --git a/src/widget/combo/mod.rs b/src/widget/combo/mod.rs index 1354131..859ae70 100644 --- a/src/widget/combo/mod.rs +++ b/src/widget/combo/mod.rs @@ -69,6 +69,9 @@ //! impl App for AppState //! { //! type Message = Msg; +//! # fn app_id( &self ) -> &str { "net.example.Combo" } +//! # fn save_state( &self ) -> Option> { None } +//! # fn restore_state( &mut self, _: Vec ) {} //! fn view( &self ) -> Element //! { //! let combo = self.build_combo(); diff --git a/tests/animation.rs b/tests/animation.rs index f09d50f..902b248 100644 --- a/tests/animation.rs +++ b/tests/animation.rs @@ -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> { None } + fn restore_state( &mut self, _state: Vec ) {} + fn view( &self ) -> Element { column::() diff --git a/tests/event_loop_flow.rs b/tests/event_loop_flow.rs index 1096f23..b4c7b36 100644 --- a/tests/event_loop_flow.rs +++ b/tests/event_loop_flow.rs @@ -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> + { + Some( self.value.to_string().into_bytes() ) + } + + fn restore_state( &mut self, state: Vec ) + { + if let Some( v ) = std::str::from_utf8( &state ).ok().and_then( |s| s.parse().ok() ) + { + self.value = v; + } + } + fn view( &self ) -> Element { column::() @@ -88,6 +103,33 @@ fn render( surface: &mut UiSurface, app: &Counter ) -> ltk::core::RenderOut ) } +// ── save_state → restore_state ──────────────────────────────────────────────── + +#[ test ] +fn save_restore_round_trip() +{ + let mut surface = UiSurface::::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 ]