Session management: xdg-session-management-v1 client, mandatory App::app_id / save_state / restore_state, runtime-managed state persistence and clean exit on signals (0.3.0)
Applications built on ltk had no way to come back where the user left them: the toolkit hardcoded `app_id = "ltk"` on every toplevel, never wrote anything to disk, and died on SIGTERM without a chance to save. This release gives the runtime the whole plumbing and asks each application only for the bytes worth keeping, in the spirit of Android's saved-instance state. The `App` trait gains three mandatory methods, deliberately without default bodies so every application states its position: `app_id()` (reverse-DNS, used for `xdg_toplevel.set_app_id`, the AccessKit application name and the state directory — the `app_id` element of the deprecated `window_config` tuple is now ignored and a one-time warning reports a mismatch), `save_state() -> Option<Vec<u8>>` and `restore_state(Vec<u8>)`. The bytes are opaque; the trait carries no serde bound. Their rustdoc is the contract: when the runtime saves, where the files live, when the bytes come back and when they do not, what must never go in them, and a worked serde_json example. The runtime persists under `$XDG_STATE_HOME/<app_id>/` (falling back to `~/.local/state`): `session.json` holds the compositor session id, a clean-exit marker and the writer's pid; `state.bin` holds the application bytes. Writes are atomic (temp file + rename, mode 0600, directory 0700) and best-effort. State is saved every 30 s when the bytes changed, once after the event loop exits (which covers `on_close_requested`, `requested_exit` and lost connections), and on SIGTERM/SIGINT — a calloop signal source, installed before any thread exists, now turns those into a clean exit of the loop instead of process death. `restore_state` runs synchronously in `try_run` before the window is created and before the first `view()`, and only when the process is relaunched as part of a session restore (`LTK_SESSION_RESTORE=1`, removed from the environment before the app can spawn children) or when the previous run left `clean_exit: false`; a plain launch starts fresh. A second concurrent instance detects the live pid and runs with persistence disabled rather than clobbering the first. The compositor side of geometry restore goes through `xdg-session-management-v1`. Neither wayland-protocols nor sctk ship generated code for it yet, so the XML is vendored under `protocols/` and `wayland-scanner` generates the client module in-tree (`src/protocol/`), resolving the crate names through sctk's reexports so the bindings stay on the crate instances sctk links. Before the first commit of a `ShellMode::Window` toplevel the runtime binds `xdg_session_manager_v1`, calls `get_session(reason, stored_id)` and `restore_toplevel(toplevel, "main")`; the three window-creation paths in `run.rs` are folded into one `make_window` helper so the attach always sits immediately before `commit()`. `created` persists the id, `replaced` destroys the objects and stops persisting. Compositors without the global lose only the geometry half. Layer-shell and session-lock surfaces skip the whole machinery. Every `App` implementor in the tree is updated: the twelve examples (`showcase`, `scroll` and `mini_shell` persist real state; the rest return `None`), both integration tests (`event_loop_flow` gains `save_restore_round_trip`), the in-source and markdown doctests, README, onboarding, cookbook (new recipe "Surviving relaunch: session state") and architecture docs, and the changelog. `src/session_state.rs` carries unit tests over a temporary state directory. `Makefile install` now copies `protocols/` into the cargo registry — without it downstream builds would fail inside the proc-macro — and `debian/copyright` covers the vendored XML. The trait change is breaking, hence 0.3.0. Also fixes the pre-existing `viewport_tests` module in `render/mod.rs`, which used `Length` without importing it and broke `cargo test`.
This commit is contained in:
+160
-1
@@ -337,6 +337,161 @@ pub trait App: 'static
|
||||
/// Apply a message to the application state.
|
||||
fn update( &mut self, msg: Self::Message );
|
||||
|
||||
/// Stable application identifier, reverse-DNS style
|
||||
/// (`"net.liberux.settings"`, `"io.eydos.Calls"`), constant for the
|
||||
/// life of the process.
|
||||
///
|
||||
/// The runtime uses it in three places:
|
||||
///
|
||||
/// * `xdg_toplevel.set_app_id` on the main window, so the compositor,
|
||||
/// the dock and the task switcher can match the window to its
|
||||
/// `.desktop` entry (use the desktop file's name minus `.desktop`,
|
||||
/// or its `StartupWMClass`);
|
||||
/// * the accessibility tree — AccessKit / AT-SPI2 report it as the
|
||||
/// application name;
|
||||
/// * the on-disk session directory `$XDG_STATE_HOME/<app_id>/`
|
||||
/// where [`save_state`](Self::save_state) is persisted.
|
||||
///
|
||||
/// It must not be empty and must not contain `/`; the runtime then
|
||||
/// disables session persistence and logs once. Layer-shell and
|
||||
/// session-lock components still return one (it names them in the
|
||||
/// a11y tree), even though they never get a toplevel. The `app_id`
|
||||
/// element of the deprecated [`window_config`](Self::window_config)
|
||||
/// tuple is ignored in favour of this method — keep both pointing at
|
||||
/// the same `const`.
|
||||
///
|
||||
/// ```rust
|
||||
/// # use ltk::{ App, Element, text };
|
||||
/// # #[ derive( Clone ) ] enum Msg {}
|
||||
/// # struct Notes;
|
||||
/// const APP_ID: &str = "net.example.Notes";
|
||||
///
|
||||
/// impl App for Notes
|
||||
/// {
|
||||
/// # type Message = Msg;
|
||||
/// # fn view( &self ) -> Element<Msg> { text( "" ).into() }
|
||||
/// # fn update( &mut self, _: Msg ) {}
|
||||
/// # fn save_state( &self ) -> Option<Vec<u8>> { None }
|
||||
/// # fn restore_state( &mut self, _: Vec<u8> ) {}
|
||||
/// fn app_id( &self ) -> &str { APP_ID }
|
||||
/// }
|
||||
/// ```
|
||||
fn app_id( &self ) -> &str;
|
||||
|
||||
/// Serialize the part of the state worth surviving a restart.
|
||||
///
|
||||
/// Return the bytes the runtime should persist, or `None` when there
|
||||
/// is nothing to bring back (a shell panel, a lock screen, a
|
||||
/// stateless tool). The encoding is the app's own — JSON, CBOR, a
|
||||
/// hand-rolled line format — ltk never inspects it and puts no serde
|
||||
/// bound on the trait. Keep it small and cheap: the runtime calls
|
||||
/// this
|
||||
///
|
||||
/// * every ~30 s while the app runs, touching the disk only when
|
||||
/// the bytes differ from the last save (a UI-only change that
|
||||
/// round-trips to identical bytes costs one comparison);
|
||||
/// * when the window closes — after
|
||||
/// [`on_close_requested`](Self::on_close_requested) allowed it or
|
||||
/// [`requested_exit`](Self::requested_exit) returned `true`;
|
||||
/// * on `SIGTERM` / `SIGINT`, which the runtime turns into a clean
|
||||
/// exit of the event loop instead of letting the process die.
|
||||
///
|
||||
/// The bytes are written atomically (temp file + rename, mode
|
||||
/// `0600`) to `$XDG_STATE_HOME/<app_id>/state.bin`
|
||||
/// (`~/.local/state/<app_id>/state.bin` when the variable is unset)
|
||||
/// next to a `session.json` holding the compositor's
|
||||
/// `xdg-session-management-v1` session id and a clean-exit marker.
|
||||
/// Returning `None` removes a stale `state.bin`. Only
|
||||
/// [`ShellMode::Window`] apps (and the deprecated
|
||||
/// [`window_config`](Self::window_config) path) are persisted;
|
||||
/// layer and session-lock surfaces are skipped silently, so shell
|
||||
/// components simply return `None`.
|
||||
///
|
||||
/// The file is plain on disk: never put secrets in it (passwords
|
||||
/// belong to the secure-mode `text_edit` and its wiped buffers),
|
||||
/// and keep large blobs (images, caches) elsewhere. The signal
|
||||
/// handler only covers threads started after [`run`](crate::run)
|
||||
/// was entered; a thread the app spawned earlier that receives
|
||||
/// `SIGTERM` still terminates the process — block the signal in
|
||||
/// such threads or start them from inside the app.
|
||||
///
|
||||
/// [`restore_state`](Self::restore_state) explains when — and when
|
||||
/// not — the bytes come back.
|
||||
///
|
||||
/// ```rust
|
||||
/// use ltk::{ App, Element, text };
|
||||
/// use serde::{ Deserialize, Serialize };
|
||||
///
|
||||
/// #[ derive( Clone ) ]
|
||||
/// enum Msg { Tab( usize ) }
|
||||
///
|
||||
/// #[ derive( Serialize, Deserialize ) ]
|
||||
/// struct Persisted { version: u32, tab: usize, draft: String }
|
||||
///
|
||||
/// struct Editor { tab: usize, draft: String, cursor_blink: bool }
|
||||
///
|
||||
/// impl App for Editor
|
||||
/// {
|
||||
/// type Message = Msg;
|
||||
///
|
||||
/// fn app_id( &self ) -> &str { "net.example.Editor" }
|
||||
///
|
||||
/// fn save_state( &self ) -> Option<Vec<u8>>
|
||||
/// {
|
||||
/// // Only what a relaunch needs — the blink phase stays out.
|
||||
/// let p = Persisted { version: 1, tab: self.tab, draft: self.draft.clone() };
|
||||
/// serde_json::to_vec( &p ).ok()
|
||||
/// }
|
||||
///
|
||||
/// fn restore_state( &mut self, state: Vec<u8> )
|
||||
/// {
|
||||
/// // Tolerate an older or corrupt file: keep the defaults.
|
||||
/// if let Ok( p ) = serde_json::from_slice::<Persisted>( &state )
|
||||
/// {
|
||||
/// self.tab = p.tab;
|
||||
/// self.draft = p.draft;
|
||||
/// }
|
||||
/// }
|
||||
/// # fn view( &self ) -> Element<Msg> { text( self.draft.clone() ).into() }
|
||||
/// # fn update( &mut self, msg: Msg ) { let Msg::Tab( t ) = msg; self.tab = t; }
|
||||
/// }
|
||||
/// ```
|
||||
fn save_state( &self ) -> Option<Vec<u8>>;
|
||||
|
||||
/// Re-apply bytes previously returned by [`save_state`](Self::save_state).
|
||||
///
|
||||
/// Called at most once per process, synchronously inside
|
||||
/// [`run`](crate::run) / [`try_run`](crate::try_run), before the
|
||||
/// window is created and before the first [`view`](Self::view) — the
|
||||
/// first frame already shows the restored state. It is **not**
|
||||
/// called on an ordinary launch. The runtime restores app state only
|
||||
/// when
|
||||
///
|
||||
/// * the process is relaunched as part of a session restore: the
|
||||
/// shell sets `LTK_SESSION_RESTORE=1` in the environment (the
|
||||
/// runtime removes the variable before the app can spawn
|
||||
/// children); or
|
||||
/// * the previous run of this `app_id` did not exit cleanly (crash,
|
||||
/// `SIGKILL`, power loss): its `session.json` still says
|
||||
/// `clean_exit: false`.
|
||||
///
|
||||
/// This mirrors Android's saved-instance-state contract: opening the
|
||||
/// app from the launcher gives a fresh instance, coming back after
|
||||
/// the system killed it lands where the user left off. Window
|
||||
/// geometry (size, position, workspace) is separate — the compositor
|
||||
/// restores it through `xdg-session-management-v1` on *every*
|
||||
/// launch, plain ones included, using the session id ltk stores.
|
||||
///
|
||||
/// `state` is exactly what `save_state` produced, possibly by an
|
||||
/// older build: validate, version, and fall back to defaults instead
|
||||
/// of panicking. Nothing is called when no state file exists or the
|
||||
/// app returned `None` last time. Shell components never receive
|
||||
/// this call and implement it as a no-op.
|
||||
///
|
||||
/// See [`save_state`](Self::save_state) for the file layout and a
|
||||
/// worked example.
|
||||
fn restore_state( &mut self, state: Vec<u8> );
|
||||
|
||||
/// Tell the runtime which surfaces *could* have changed visibly as a
|
||||
/// result of [`update`](Self::update) being called with this message.
|
||||
///
|
||||
@@ -777,7 +932,8 @@ pub trait App: 'static
|
||||
/// Return `Some(( title, app_id ))` to force an XDG toplevel window instead of
|
||||
/// layer-shell overlay. The compositor will display the title in the title bar
|
||||
/// and use the app_id for taskbar/icon matching. Return `None` (default) to
|
||||
/// use layer-shell when available.
|
||||
/// use layer-shell when available. The `app_id` element is ignored:
|
||||
/// [`app_id`](Self::app_id) names the window.
|
||||
///
|
||||
/// **Deprecated**: Use [`shell_mode`](Self::shell_mode) instead.
|
||||
fn window_config( &self ) -> Option<( &str, &str )> { None }
|
||||
@@ -961,6 +1117,9 @@ pub fn run<A: App>( app: A )
|
||||
/// # struct MyApp;
|
||||
/// # impl App for MyApp {
|
||||
/// # type Message = Msg;
|
||||
/// # fn app_id( &self ) -> &str { "net.example.MyApp" }
|
||||
/// # fn save_state( &self ) -> Option<Vec<u8>> { None }
|
||||
/// # fn restore_state( &mut self, _: Vec<u8> ) {}
|
||||
/// # fn view( &self ) -> Element<Msg> { button( "x" ).into() }
|
||||
/// # fn update( &mut self, _: Msg ) {}
|
||||
/// # }
|
||||
|
||||
@@ -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;
|
||||
|
||||
+54
-22
@@ -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( () )
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user