From 470a65b9af70927e99fe633a1d8626bd19a52060 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:31:01 +0200 Subject: [PATCH 01/14] test: play external helpers with the test binary, not scripts Four tests wrote and ran /bin/sh scripts as stand-ins for the configurator, an initial-detach probe, and wl-copy. A test-only constructor now lets the test binary play those helpers instead: a test links the helper's name to the test binary and names that link in the environment, and only a start under exactly that path plays the role, so other children run the tests. - Configurator override and tray settings launch: exit, or record the arguments the tray passed. - Initial detach: report whether the child leads its process group. - wl-copy publication: count the bytes it was given. - OCR path probes only need an executable file, so write an empty one. --- src/daemon/tests.rs | 80 +++++++---------- src/lib.rs | 2 + src/ocr/tesseract.rs | 4 +- src/process_broker/tests.rs | 167 +++++++++++++++++------------------- src/test_fake_helper.rs | 158 ++++++++++++++++++++++++++++++++++ 5 files changed, 272 insertions(+), 139 deletions(-) create mode 100644 src/test_fake_helper.rs diff --git a/src/daemon/tests.rs b/src/daemon/tests.rs index a1a82322..8744352c 100644 --- a/src/daemon/tests.rs +++ b/src/daemon/tests.rs @@ -224,60 +224,38 @@ fn tray_menu_offers_session_settings_instead_of_a_session_toggle() { #[cfg(feature = "tray")] #[test] fn tray_session_settings_item_opens_the_configurator_at_the_session_screen() { - use std::os::unix::fs::PermissionsExt; - - let _environment = crate::test_env::lock(); let temp = crate::test_temp::tempdir().expect("tempdir"); let recorded = temp.path().join("arguments"); - let recorder = temp.path().join("recording-configurator"); - std::fs::write( - &recorder, - // Written under a scratch name and renamed so a read either sees the - // whole argument list or no file at all. - format!( - "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{0}.part'\nmv '{0}.part' '{0}'\n", - recorded.display() + let recorder = crate::test_fake_helper::link(temp.path(), "recording-configurator"); + // The configurator override makes the broker accept the stand-in; the + // config home keeps the launch failure path away from the developer's own + // file. The previous values come back before the assertion. + let mut variables = vec![ + ( + crate::env_vars::CONFIGURATOR_ENV, + Some(recorder.as_os_str()), ), - ) - .expect("the recording configurator should be written"); - let mut permissions = std::fs::metadata(&recorder) - .expect("the recording configurator should exist") - .permissions(); - permissions.set_mode(0o700); - std::fs::set_permissions(&recorder, permissions) - .expect("the recording configurator should be executable"); - - let previous_configurator = std::env::var_os(crate::env_vars::CONFIGURATOR_ENV); - let previous_config_home = std::env::var_os(crate::env_vars::XDG_CONFIG_HOME_ENV); - // SAFETY: access to the process environment is serialized by test_env. The - // configurator override makes the broker accept the stand-in; the config - // home keeps the launch failure path away from the developer's own file. - unsafe { - std::env::set_var(crate::env_vars::CONFIGURATOR_ENV, &recorder); - std::env::set_var(crate::env_vars::XDG_CONFIG_HOME_ENV, temp.path()); - } - - let toggle = Arc::new(AtomicBool::new(false)); - let quit = Arc::new(AtomicBool::new(false)); - let mut tray = WayscriberTray::new_for_tests_with_configurator( - toggle, - quit, - recorder.to_string_lossy().into_owned(), - ); - let launched = record_session_settings_launch(&mut tray, &recorded); - - // SAFETY: as above; the previous values are restored before the assertion - // so a failure cannot leak this test's environment into the next one. - unsafe { - match previous_configurator { - Some(value) => std::env::set_var(crate::env_vars::CONFIGURATOR_ENV, value), - None => std::env::remove_var(crate::env_vars::CONFIGURATOR_ENV), - } - match previous_config_home { - Some(value) => std::env::set_var(crate::env_vars::XDG_CONFIG_HOME_ENV, value), - None => std::env::remove_var(crate::env_vars::XDG_CONFIG_HOME_ENV), - } - } + ( + crate::env_vars::XDG_CONFIG_HOME_ENV, + Some(temp.path().as_os_str()), + ), + ]; + variables.extend(crate::test_fake_helper::environment( + &recorder, + crate::test_fake_helper::Role::RecordArguments, + &recorded, + )); + + let launched = crate::test_env::with_env_vars(&variables, || { + let toggle = Arc::new(AtomicBool::new(false)); + let quit = Arc::new(AtomicBool::new(false)); + let mut tray = WayscriberTray::new_for_tests_with_configurator( + toggle, + quit, + recorder.to_string_lossy().into_owned(), + ); + record_session_settings_launch(&mut tray, &recorded) + }); assert_eq!( launched.as_deref(), diff --git a/src/lib.rs b/src/lib.rs index 667a77b7..bf90d58b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,8 @@ pub mod systemd_user_service; #[cfg(test)] pub(crate) mod test_env; #[cfg(test)] +pub(crate) mod test_fake_helper; +#[cfg(test)] pub(crate) mod test_temp; pub mod time_utils; mod toolbar_gtk; diff --git a/src/ocr/tesseract.rs b/src/ocr/tesseract.rs index 50e3f312..b56e1a08 100644 --- a/src/ocr/tesseract.rs +++ b/src/ocr/tesseract.rs @@ -317,7 +317,7 @@ mod tests { let directory = crate::test_temp::tempdir().unwrap(); let program = directory.path().join("wayscriber-fake-ocr"); - std::fs::write(&program, b"#!/bin/sh\n").unwrap(); + std::fs::write(&program, b"").unwrap(); let search_path = directory.path().as_os_str(); assert!( @@ -339,7 +339,7 @@ mod tests { let directory = crate::test_temp::tempdir().unwrap(); let program = directory.path().join("wayscriber-fake-ocr"); - std::fs::write(&program, b"#!/bin/sh\n").unwrap(); + std::fs::write(&program, b"").unwrap(); std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); // An empty entry means "current directory" to some shells; treating it diff --git a/src/process_broker/tests.rs b/src/process_broker/tests.rs index a57553c5..aae321d3 100644 --- a/src/process_broker/tests.rs +++ b/src/process_broker/tests.rs @@ -36,42 +36,43 @@ fn release_test_provider( #[test] fn configurator_manifest_preserves_arbitrary_explicit_override_name() { - let _guard = crate::test_env::lock(); - let variable = crate::env_vars::CONFIGURATOR_ENV; - let previous = std::env::var_os(variable); let temp = crate::test_temp::tempdir().unwrap(); - let configured = temp.path().join("open-wayscriber-settings"); - std::fs::write(&configured, "#!/bin/sh\nexit 0\n").unwrap(); - let mut permissions = std::fs::metadata(&configured).unwrap().permissions(); - std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o700); - std::fs::set_permissions(&configured, permissions).unwrap(); - // SAFETY: access to the process environment is serialized by test_env. - unsafe { std::env::set_var(variable, &configured) }; - - let program = super::wire::OsWire::from_os(configured.as_os_str()).unwrap(); - let result = super::manifest::validate(HelperKind::Configurator, &program, &[], &[], &[]); - let broker_result = (|| -> anyhow::Result<()> { - let guard = start_for_runtime()?; - guard.broker().spawn( - HelperKind::Configurator, - HelperLifetime::DetachedAfterExec, - configured.as_os_str(), - std::iter::empty::<&OsStr>(), - Vec::new(), - )?; - Ok(()) - })(); - let unexpected = super::wire::OsWire::from_os(OsStr::new("/tmp/unrelated-program")).unwrap(); - let unexpected_result = - super::manifest::validate(HelperKind::Configurator, &unexpected, &[], &[], &[]); - - if let Some(previous) = previous { - // SAFETY: access to the process environment is serialized by test_env. - unsafe { std::env::set_var(variable, previous) }; - } else { - // SAFETY: access to the process environment is serialized by test_env. - unsafe { std::env::remove_var(variable) }; - } + let configured = crate::test_fake_helper::link(temp.path(), "open-wayscriber-settings"); + let output = temp.path().join("unused"); + let mut variables = vec![( + crate::env_vars::CONFIGURATOR_ENV, + Some(configured.as_os_str()), + )]; + variables.extend(crate::test_fake_helper::environment( + &configured, + crate::test_fake_helper::Role::Exit, + &output, + )); + + let (result, broker_result, unexpected_result) = + crate::test_env::with_env_vars(&variables, || { + let program = super::wire::OsWire::from_os(configured.as_os_str()).unwrap(); + let result = + super::manifest::validate(HelperKind::Configurator, &program, &[], &[], &[]); + let broker_result = (|| -> anyhow::Result<()> { + let guard = start_for_runtime()?; + guard.broker().spawn( + HelperKind::Configurator, + HelperLifetime::DetachedAfterExec, + configured.as_os_str(), + std::iter::empty::<&OsStr>(), + Vec::new(), + )?; + Ok(()) + })(); + let unexpected = + super::wire::OsWire::from_os(OsStr::new("/tmp/unrelated-program")).unwrap(); + let unexpected_result = + super::manifest::validate(HelperKind::Configurator, &unexpected, &[], &[], &[]); + + (result, broker_result, unexpected_result) + }); + result.unwrap(); broker_result.unwrap(); assert!(unexpected_result.is_err()); @@ -548,46 +549,37 @@ fn process_group_guard_cleans_up_before_ownership_transfer() { #[test] fn initial_detach_child_remains_eligible_to_create_a_session() { let temp = crate::test_temp::tempdir().unwrap(); - let helper = temp.path().join("wayscriber-detach-probe"); + let helper = crate::test_fake_helper::link(temp.path(), "wayscriber-detach-probe"); let proof = temp.path().join("detach-state"); - std::fs::write( + let variables = crate::test_fake_helper::environment( &helper, - r#"#!/bin/sh -read -r pid comm state ppid pgrp rest < "/proc/$$/stat" -if [ "$pid" = "$pgrp" ]; then - printf process-group-leader > "$1" -else - printf session-eligible > "$1" -fi -"#, - ) - .unwrap(); - let mut permissions = std::fs::metadata(&helper).unwrap().permissions(); - std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o700); - std::fs::set_permissions(&helper, permissions).unwrap(); + crate::test_fake_helper::Role::ReportProcessGroup, + &proof, + ); - let guard = start_for_runtime().unwrap(); - let _child = guard - .broker() - .spawn( - HelperKind::InitialDetach, - HelperLifetime::DetachedAfterExec, - helper.as_os_str(), - [proof.as_os_str()], - Vec::new(), - ) - .unwrap(); + let observed = crate::test_env::with_env_vars(&variables, || { + let guard = start_for_runtime().unwrap(); + let _child = guard + .broker() + .spawn( + HelperKind::InitialDetach, + HelperLifetime::DetachedAfterExec, + helper.as_os_str(), + std::iter::empty::<&OsStr>(), + Vec::new(), + ) + .unwrap(); - let deadline = Instant::now() + Duration::from_secs(1); - let observed = loop { - if let Ok(value) = std::fs::read_to_string(&proof) - && matches!(value.as_str(), "session-eligible" | "process-group-leader") - { - break value; + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Ok(value) = std::fs::read_to_string(&proof) { + break value; + } + assert!(Instant::now() < deadline, "detach probe did not complete"); + std::thread::sleep(Duration::from_millis(5)); } - assert!(Instant::now() < deadline, "detach probe did not complete"); - std::thread::yield_now(); - }; + }); + assert_eq!(observed, "session-eligible"); } @@ -1335,25 +1327,28 @@ fn broker_shutdown_preempts_retained_publication_stdin_writer() { #[test] fn wl_copy_publication_accepts_capture_sized_input() { const PUBLICATION_BYTES: usize = 16 * 1024 * 1024 + 1; - let guard = start_for_runtime().unwrap(); let temp = crate::test_temp::tempdir().unwrap(); - let helper = temp.path().join("wl-copy"); + let helper = crate::test_fake_helper::link(temp.path(), "wl-copy"); let count_path = temp.path().join("published-bytes"); - std::fs::write(&helper, "#!/bin/sh\nwc -c > \"$1\"\n").unwrap(); - let mut permissions = std::fs::metadata(&helper).unwrap().permissions(); - std::os::unix::fs::PermissionsExt::set_mode(&mut permissions, 0o700); - std::fs::set_permissions(&helper, permissions).unwrap(); + let variables = crate::test_fake_helper::environment( + &helper, + crate::test_fake_helper::Role::CountInput, + &count_path, + ); - let output = guard - .broker() - .publish( - HelperKind::WlCopy, - helper.as_os_str(), - [count_path.as_os_str()], - vec![b'x'; PUBLICATION_BYTES], - Duration::from_secs(30), - ) - .unwrap(); + let output = crate::test_env::with_env_vars(&variables, || { + let guard = start_for_runtime().unwrap(); + guard + .broker() + .publish( + HelperKind::WlCopy, + helper.as_os_str(), + std::iter::empty::<&OsStr>(), + vec![b'x'; PUBLICATION_BYTES], + Duration::from_secs(30), + ) + .unwrap() + }); assert_eq!(output.status, 0); assert!(!output.timed_out); diff --git a/src/test_fake_helper.rs b/src/test_fake_helper.rs new file mode 100644 index 00000000..800acf32 --- /dev/null +++ b/src/test_fake_helper.rs @@ -0,0 +1,158 @@ +//! External helper programs, played by this test binary. +//! +//! A test links a helper's name to the test binary with [`link`], then sets the +//! [`environment`] for that link while it launches the helper. When this binary +//! starts under exactly that path, the constructor below plays the requested +//! [`Role`] and exits before libtest runs. Any other start, such as another +//! test's child launched while the variables are set, runs the tests as usual, +//! so no test writes a script or relies on a system program. + +use std::ffi::OsStr; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +/// The path a helper start must have been launched under to play a role. +const LINK_ENV: &str = "WAYSCRIBER_TEST_FAKE_HELPER"; +const ROLE_ENV: &str = "WAYSCRIBER_TEST_FAKE_HELPER_ROLE"; +/// Where a role that reports writes its report. +const OUTPUT_ENV: &str = "WAYSCRIBER_TEST_FAKE_HELPER_OUTPUT"; + +#[derive(Clone, Copy, Debug)] +pub(crate) enum Role { + /// Exits successfully. + Exit, + /// Reports `process-group-leader` if it leads its process group, else + /// `session-eligible`: only a non-leader may call `setsid()`. + ReportProcessGroup, + /// Reads its standard input to the end and reports the byte count. + CountInput, + /// Reports its arguments, one per line. + RecordArguments, +} + +impl Role { + const ALL: [Self; 4] = [ + Self::Exit, + Self::ReportProcessGroup, + Self::CountInput, + Self::RecordArguments, + ]; + + fn name(self) -> &'static str { + match self { + Self::Exit => "exit", + Self::ReportProcessGroup => "report-process-group", + Self::CountInput => "count-input", + Self::RecordArguments => "record-arguments", + } + } +} + +/// A link named `name` in `directory` to this test binary. +pub(crate) fn link(directory: &Path, name: &str) -> PathBuf { + let link = directory.join(name); + std::os::unix::fs::symlink(std::env::current_exe().expect("test binary"), &link) + .expect("link the test binary under the helper's name"); + link +} + +/// The variables that make a start under `link` play `role`, reporting to +/// `output`. Set them, for example with `test_env::with_env_vars`, while the +/// helper is launched. +pub(crate) fn environment<'a>( + link: &'a Path, + role: Role, + output: &'a Path, +) -> [(&'static str, Option<&'a OsStr>); 3] { + [ + (LINK_ENV, Some(link.as_os_str())), + (ROLE_ENV, Some(OsStr::new(role.name()))), + (OUTPUT_ENV, Some(output.as_os_str())), + ] +} + +// SAFETY: the loader calls each `.init_array` entry once, before `main`, with +// the C calling convention. glibc passes `argc`, `argv`, and `envp`; under the +// C ABI a function that declares no parameters ignores extra arguments, so an +// `extern "C" fn()` is sound to register here. Unless this start is a helper +// start, the function only reads the environment and `/proc` and returns. A +// helper start never returns into `main`: it exits, and a panic aborts instead +// of unwinding through the loader because the function is `extern "C"`. +#[used] +#[unsafe(link_section = ".init_array")] +static PLAY_FAKE_HELPER: extern "C" fn() = play_fake_helper; + +extern "C" fn play_fake_helper() { + let Some(link) = std::env::var_os(LINK_ENV) else { + return; + }; + let Ok(mut arguments) = launch_arguments() else { + return; + }; + if arguments.is_empty() || OsStr::new(&arguments.remove(0)) != link { + return; + } + + let status = match play(&arguments) { + Ok(()) => 0, + Err(error) => { + eprintln!("fake helper failed: {error:#}"); + 1 + } + }; + std::process::exit(status); +} + +fn play(arguments: &[String]) -> Result<()> { + let role = std::env::var(ROLE_ENV).context("fake helper role")?; + let role = Role::ALL + .into_iter() + .find(|known| known.name() == role) + .with_context(|| format!("unknown fake helper role {role:?}"))?; + let output = std::env::var_os(OUTPUT_ENV).context("fake helper output")?; + + let report = match role { + Role::Exit => return Ok(()), + Role::ReportProcessGroup => { + // SAFETY: getpgrp has no preconditions and cannot fail. + let leader = unsafe { libc::getpgrp() } == std::process::id() as libc::pid_t; + if leader { + "process-group-leader" + } else { + "session-eligible" + } + .to_owned() + } + Role::CountInput => { + let mut input = Vec::new(); + std::io::stdin() + .read_to_end(&mut input) + .context("read the helper's input")?; + format!("{}\n", input.len()) + } + Role::RecordArguments => arguments + .iter() + .map(|argument| format!("{argument}\n")) + .collect(), + }; + + crate::durable_io::write_atomic( + Path::new(&output), + report.as_bytes(), + crate::durable_io::AtomicWriteOptions::private_runtime_file(), + )?; + Ok(()) +} + +/// The arguments this process was launched with, `argv[0]` first: std's own +/// argument capture may not have run before this constructor. +fn launch_arguments() -> Result> { + let raw = std::fs::read("/proc/self/cmdline").context("read /proc/self/cmdline")?; + let raw = raw.strip_suffix(b"\0").unwrap_or(&raw); + + raw.split(|byte| *byte == 0) + .map(|argument| String::from_utf8(argument.to_vec()).context("non-UTF-8 argument")) + .collect() +} From 7c85d8d9bfcc7bf730b267393070f0968fff9853 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:32:24 +0200 Subject: [PATCH 02/14] test(gtk): close proxy connections before joining its listeners A forwarder blocked on a hung peer holds its connection's state lock, so a control command waiting for that lock kept its listener from finishing and the proxy's teardown from returning. Close the connections first, which wakes the forwarder, then join the listeners and close any connection accepted meanwhile. --- .../tests/wayland_proxy/relay.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/toolbar_gtk/view/capture_suppression/tests/wayland_proxy/relay.rs b/src/toolbar_gtk/view/capture_suppression/tests/wayland_proxy/relay.rs index 3178dcbc..b2ababdb 100644 --- a/src/toolbar_gtk/view/capture_suppression/tests/wayland_proxy/relay.rs +++ b/src/toolbar_gtk/view/capture_suppression/tests/wayland_proxy/relay.rs @@ -60,6 +60,11 @@ impl Proxy { impl Drop for Proxy { fn drop(&mut self) { self.shared.stopping.store(true, Ordering::SeqCst); + // Close the connections before joining the listeners: a forwarder + // blocked on a hung peer holds its connection's state lock, and a + // control command waiting for that lock would never let its listener + // finish. Closing the socket wakes the forwarder. + self.shared.shutdown_connections(); for (path, listener) in self.listeners.drain(..) { // A connection wakes the blocking accept so the thread sees `stopping`. let wake = UnixStream::connect(&path); @@ -67,10 +72,9 @@ impl Drop for Proxy { drop(wake); } - // No listener runs now, so the connection list is final. - for connection in lock(&self.shared.connections).iter() { - connection.shutdown(); - } + // No listener runs now, so the connection list is final; close any + // accepted while the listeners were stopping. + self.shared.shutdown_connections(); let forwarders = std::mem::take(&mut *lock(&self.shared.forwarders)); for forwarder in forwarders { let _ = forwarder.join(); @@ -79,6 +83,12 @@ impl Drop for Proxy { } impl Shared { + fn shutdown_connections(&self) { + for connection in lock(&self.connections).iter() { + connection.shutdown(); + } + } + fn accept_clients(&self, listener: UnixListener) { for client in listener.incoming() { if self.stopping.load(Ordering::SeqCst) { From 1dbe5f7ef4f5c419f6063ed6cb48a820a5e11ae0 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:33:07 +0200 Subject: [PATCH 03/14] docs(guards): state what the no-Python guard reads and skips The guard's header claimed it read MSBuild and packaging files in general, but it reads only listed extensions and extensionless files; other types, such as .csproj or .json, are checked by name. It also honors a `!` line in .gitignore only when it names one file. Say both, and list them among the known limits. --- tests/repository_guards/no_python.rs | 17 +++++++++++------ tools/README.md | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/repository_guards/no_python.rs b/tests/repository_guards/no_python.rs index 8333ca38..6c907619 100644 --- a/tests/repository_guards/no_python.rs +++ b/tests/repository_guards/no_python.rs @@ -3,8 +3,9 @@ //! Tools are C# or POSIX shell, and tests and fixtures are Rust. This fails on a //! Python file or a link to one, a Python project or lock file, a Python //! shebang, or a Python interpreter or package name in the sources it reads: -//! Rust, C#, MSBuild, shell, Nix, TOML, workflow, build, service, desktop-entry, -//! and packaging files. So neither a script embedded in a string literal, an +//! `.rs`, `.cs`, MSBuild `.props` and `.targets`, `.sh`, `.nix`, `.toml`, YAML +//! (workflows and package manifests), `.service` and `.desktop` files, and every +//! extensionless file. So neither a script embedded in a string literal, an //! interpreter launched by the tools, nor a declared interpreter dependency can //! come back. Markdown is not read, so documentation can still say that the //! repository uses no Python, and a `python` domain label such as @@ -13,7 +14,7 @@ //! The walk covers every directory and file at the root except `.git`, build //! output (`target/` and any root directory holding a `CACHEDIR.TAG`), and the //! local files named by the root `.gitignore`. Inside an ignored directory such -//! as `packaging/`, the files that `.gitignore` re-includes one by one are read. +//! as `packaging/`, the files that a `!` line re-includes by name are read. //! Extensionless files, such as `PKGBUILD` or a script, are read in full. It //! walks the checkout rather than asking Git, so it also runs in a Nix build, //! which has no `.git`. @@ -22,9 +23,12 @@ //! not as a sandbox. Known limits: an interpreter reached under another name, //! such as `pypy3`, `PYTHONPATH`, or `buildPythonApplication`, is not seen; a //! `python` path segment that is not a domain label, as in -//! `github.com/python/cpython`, is reported, so such a link is reworded; and +//! `github.com/python/cpython`, is reported, so such a link is reworded; //! an untracked directory that is not ignored and holds no `CACHEDIR.TAG`, such -//! as `.direnv/` or `node_modules/`, is read like a source directory. +//! as `.direnv/` or `node_modules/`, is read like a source directory; other file +//! types, such as `.csproj`, `.json`, `.spec`, or `.install`, are checked by name +//! only; and a file that `.gitignore` re-includes only through a `!` line with a +//! glob or a directory, such as `!packaging/*.sh`, stays skipped. use std::collections::BTreeMap; use std::fs; @@ -77,7 +81,8 @@ struct LocalEntry { /// The local files and directories the root `.gitignore` names. Only plain /// names, which match at the root, and anchored paths with a `/` inside are /// read; globs are left out, so this skips no more than Git ignores. A file Git -/// tracks despite a matching entry is skipped too, unless a `!` line names it. +/// tracks despite a matching entry is skipped too, unless a `!` line names that +/// one file; `!` lines with a glob or naming a directory are not honored. #[derive(Debug, Default, PartialEq)] struct LocalFiles { root_names: Vec, diff --git a/tools/README.md b/tools/README.md index 657f7397..799b73b4 100644 --- a/tools/README.md +++ b/tools/README.md @@ -89,7 +89,7 @@ shell version. Run `--help` for the complete command list and options. - `config_writers.rs` rejects `config.toml` write capability outside the configurator's Save and the overlay's pinned narrow editors - `process_sites.rs` keeps process creation inside the process broker and audits the broker's post-fork child stub - `shared_dependencies.rs` keeps the shared domain and config validation layers free of upward crate paths, using the syntax corpus beside it - - `no_python.rs` rejects Python files and links to them, Python project and lock files, Python shebangs, and Python interpreter or package names in the Rust, C#, MSBuild, shell, Nix, TOML, workflow, build, service, desktop-entry, packaging, and extensionless files it reads; its known limits are listed at the top of the file + - `no_python.rs` rejects Python files and links to them, Python project and lock files, Python shebangs, and Python interpreter or package names in the `.rs`, `.cs`, `.props`, `.targets`, `.sh`, `.nix`, `.toml`, YAML, `.service`, `.desktop`, and extensionless files it reads; other files are checked by name, and its known limits are listed at the top of the file - Each guard carries regression fixtures for the escapes it forbids - Usage: `cargo test --test repository_guards` From ac35f7c31dcd114479e86f28f898f8ae9e34e212 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:54:25 +0200 Subject: [PATCH 04/14] feat(session): continue a remembered session and report session changes A daemon overlay can now be told, through optional launch variables, the daemon's home session and a remembered session to continue in place of home. An explicit --session-file other than home outranks the remembered one. Before the first load, the overlay checks that the remembered session still exists; when it was moved or deleted it starts at home instead and says so, rather than creating an empty session at the missing path. Whenever an explicit command commits a new target, and when the first load falls back home, the overlay reports its session to the daemon in a per-generation record under daemon-commands/overlay-targets/, outside the strict v2 tree. Home is reported as home even when it is a named file, an unchanged target is not reported again, and only a daemon that advertises support receives reports. A failed report leaves the switch in place and says that the session may not reopen after the overlay hides. No daemon sets these variables yet, so behavior is unchanged until the daemon starts reading the reports. --- src/backend/wayland/backend/state_init/mod.rs | 26 ++- .../wayland/backend/state_init/session.rs | 21 ++- src/backend/wayland/session.rs | 14 ++ src/backend/wayland/session/driver.rs | 5 + src/backend/wayland/session/driver/tests.rs | 134 ++++++++++++++ src/backend/wayland/session/home.rs | 164 +++++++++++++++++ src/backend/wayland/session/home/tests.rs | 171 ++++++++++++++++++ src/backend/wayland/state.rs | 2 + src/backend/wayland/state/core/init.rs | 2 + src/backend/wayland/state/core/mod.rs | 1 + .../wayland/state/core/output/transition.rs | 1 + src/backend/wayland/state/core/session.rs | 4 + .../wayland/state/core/session_home.rs | 82 +++++++++ .../wayland/state/toolbar/events/session.rs | 8 +- src/daemon/protocol_v2/mod.rs | 2 + src/daemon/protocol_v2/session_target.rs | 131 ++++++++++++++ .../protocol_v2/session_target/tests.rs | 74 ++++++++ src/env_vars.rs | 8 + src/ui/toolbar/session_format.rs | 11 ++ 19 files changed, 849 insertions(+), 12 deletions(-) create mode 100644 src/backend/wayland/session/home.rs create mode 100644 src/backend/wayland/session/home/tests.rs create mode 100644 src/backend/wayland/state/core/session_home.rs create mode 100644 src/daemon/protocol_v2/session_target.rs create mode 100644 src/daemon/protocol_v2/session_target/tests.rs diff --git a/src/backend/wayland/backend/state_init/mod.rs b/src/backend/wayland/backend/state_init/mod.rs index 802f3008..8bb992f6 100644 --- a/src/backend/wayland/backend/state_init/mod.rs +++ b/src/backend/wayland/backend/state_init/mod.rs @@ -9,6 +9,7 @@ use super::WaylandBackend; use super::runtime_wake::RuntimeWakeSource; use super::setup::WaylandSetup; use crate::backend::wayland::portal_capture::portal_freeze_fallback; +use crate::backend::wayland::session::{SessionHome, SessionLaunch, session_target}; use crate::env_vars::{DESKTOP_SESSION_ENV, XDG_CURRENT_DESKTOP_ENV, XDG_SESSION_DESKTOP_ENV}; use crate::{ capture::CaptureManager, @@ -46,8 +47,28 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul let session_config_failed = load_failure .as_ref() .is_some_and(|failure| failure.section_failed("session")); - let session_options = - session::build_session_options(&config, &config_dir, backend.named_session_file.clone()); + let launch = SessionLaunch::from_environment(backend.named_session_file.as_deref()); + let home_options = session::session_options_for( + &config, + &config_dir, + launch.home.file().map(Path::to_path_buf), + ); + if let Some(preferred) = &launch.preferred { + info!("Continuing remembered session {}", preferred.display()); + } + let session_options = session::build_session_options( + &config, + &config_dir, + launch + .preferred + .clone() + .or_else(|| backend.named_session_file.clone()), + ); + let session_home = SessionHome::new( + launch, + home_options, + session_target(session_options.as_ref()), + ); let runtime_wake = RuntimeWakeSource::new() .map_err(|err| anyhow::anyhow!("failed to create runtime wake descriptor: {err}"))?; let persistence = @@ -194,6 +215,7 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul palette_recents, capture_manager, session_options, + session_home, session_config_failed, persistence, runtime_ui, diff --git a/src/backend/wayland/backend/state_init/session.rs b/src/backend/wayland/backend/state_init/session.rs index 35637246..df919b1d 100644 --- a/src/backend/wayland/backend/state_init/session.rs +++ b/src/backend/wayland/backend/state_init/session.rs @@ -8,10 +8,23 @@ use crate::{RESUME_SESSION_ENV, paths, session}; use super::super::helpers::resume_override_from_env; +/// The options this run starts with, logged. pub(super) fn build_session_options( config: &Config, config_dir: &Path, named_session_file: Option, +) -> Option { + let session_options = session_options_for(config, config_dir, named_session_file); + log_session_options(session_options.as_ref()); + session_options +} + +/// The options for a run in `named_session_file`, or in the configured +/// default session without one; `None` when that has persistence disabled. +pub(super) fn session_options_for( + config: &Config, + config_dir: &Path, + named_session_file: Option, ) -> Option { let display_env = env::var(WAYLAND_DISPLAY_ENV).ok(); let resume_override = resume_override_from_env(); @@ -76,7 +89,11 @@ pub(super) fn build_session_options( None => {} } - if let Some(ref opts) = session_options { + session_options +} + +fn log_session_options(session_options: Option<&session::SessionOptions>) { + if let Some(opts) = session_options { info!( "Session persistence: base_dir={}, per_output={}, display_id='{}', output_identity={:?}, boards[T/W/B]={}/{}/{}, history={}, max_persisted_history={:?}, restore_tool_state={}, autosave_enabled={}, autosave_idle_ms={}, autosave_interval_ms={}, autosave_failure_backoff_ms={}, max_file_size={} bytes, compression={:?}", opts.base_dir.display(), @@ -99,8 +116,6 @@ pub(super) fn build_session_options( } else { info!("Session persistence disabled (no session options available)"); } - - session_options } #[cfg(test)] diff --git a/src/backend/wayland/session.rs b/src/backend/wayland/session.rs index 3ea2280f..4d3dea38 100644 --- a/src/backend/wayland/session.rs +++ b/src/backend/wayland/session.rs @@ -93,6 +93,18 @@ impl SessionState { self.options.as_ref() } + /// Starts this run in `options` instead, before any session was loaded. + pub(in crate::backend::wayland) fn replace_options_before_load( + &mut self, + options: Option, + ) { + debug_assert!( + !self.loaded, + "a loaded session changes target through a commit" + ); + self.options = options; + } + /// Returns mutable access to the session options, if present. #[allow(dead_code)] pub fn options_mut(&mut self) -> Option<&mut SessionOptions> { @@ -579,9 +591,11 @@ fn autosave_active(options: &SessionOptions) -> bool { } pub(in crate::backend::wayland) mod driver; +mod home; mod persistence; mod runtime; +pub(in crate::backend::wayland) use home::{SessionHome, SessionLaunch, session_target}; pub(in crate::backend::wayland) use persistence::{ PersistenceCompletion, PersistenceController, PersistenceOperation, PersistenceOutcome, RequestId, SaveCompletion, SaveStrategy, SubmitFailure, diff --git a/src/backend/wayland/session/driver.rs b/src/backend/wayland/session/driver.rs index 44cff107..0298694d 100644 --- a/src/backend/wayland/session/driver.rs +++ b/src/backend/wayland/session/driver.rs @@ -14,6 +14,9 @@ pub(in crate::backend::wayland) trait SessionCommandRuntime { fn persistence(&mut self) -> &mut PersistenceController; fn session_config_failed(&self) -> bool; fn refresh_session_ui_seeds(&mut self); + /// An explicit command committed its target, or finished without changing + /// it. Called before the command's terminal report. + fn session_target_committed(&mut self); fn finish_session_command(&mut self, report: SessionCommandReport); fn fail_session_command(&mut self, command: &SessionCommand, error: &anyhow::Error); fn autosave_succeeded(&mut self, save: SaveCompletion, execution_time: Duration); @@ -118,6 +121,7 @@ fn advance_session_command( Ok(TransactionStep::Work(operation)) => { if transaction.has_committed_open() { runtime.refresh_session_ui_seeds(); + runtime.session_target_committed(); } let epoch = runtime.session_context().session.target_epoch(); @@ -145,6 +149,7 @@ fn advance_session_command( runtime.refresh_session_ui_seeds(); } + runtime.session_target_committed(); runtime.finish_session_command(*report); } Err(error) => { diff --git a/src/backend/wayland/session/driver/tests.rs b/src/backend/wayland/session/driver/tests.rs index abd03273..dacdd887 100644 --- a/src/backend/wayland/session/driver/tests.rs +++ b/src/backend/wayland/session/driver/tests.rs @@ -28,6 +28,9 @@ pub(in crate::backend::wayland::session) struct CommandRuntime<'a> { config_failed: bool, reports: Vec, errors: Vec, + /// The target at each commit notice, in order, with the number of + /// terminal reports published before it. + pub committed_targets: Vec<(Option, usize)>, ui: Option, ui_engine: crate::ui_text::UiTextEngine, chrome: ToolbarChrome, @@ -54,6 +57,7 @@ impl<'a> CommandRuntime<'a> { config_failed: false, reports: Vec::new(), errors: Vec::new(), + committed_targets: Vec::new(), ui: None, ui_engine: crate::ui_text::UiTextEngine::default(), chrome: ToolbarChrome::new(true, (0.0, 0.0)), @@ -161,6 +165,13 @@ impl SessionCommandRuntime for CommandRuntime<'_> { refresh_runtime_ui_config_seeds(self); } + fn session_target_committed(&mut self) { + self.committed_targets.push(( + self.session.options().map(|options| options.target.clone()), + self.reports.len(), + )); + } + fn finish_session_command(&mut self, report: SessionCommandReport) { self.reports.push(report); } @@ -573,6 +584,129 @@ fn open_refreshes_consumer_seeds_before_catalog_work_and_at_completion() { } } +#[test] +fn committed_open_announces_its_target_before_the_terminal_report() { + for catalog in [ + CatalogOutcome::Success, + CatalogOutcome::Failure, + CatalogOutcome::Rejected, + ] { + let temp = crate::test_temp::tempdir().unwrap(); + let current = named_options(temp.path(), "current"); + let target = named_options(temp.path(), "target"); + stored_session::save_snapshot(&sample_snapshot(), &target).unwrap(); + let _env = EnvGuard::set_xdg_data_home(temp.path()); + if catalog == CatalogOutcome::Failure { + std::fs::write(temp.path().join("wayscriber"), b"catalog blocked").unwrap(); + } + let mut input = test_input_state(); + let mut session = SessionState::new(Some(current)); + let measurer = TextMeasurer::default(); + let (persistence, worker) = PersistenceController::controlled_for_test(); + let mut runtime = CommandRuntime::new(&mut input, &measurer, &mut session, persistence); + let committed = (Some(target.target.clone()), 0); + + start_session_command( + &mut runtime, + SessionCommand::Open(target.session_file_path()), + ) + .unwrap(); + worker.complete_next(); // open preflight + runtime.receive(); + assert!(runtime.committed_targets.is_empty()); + + worker.complete_next(); // candidate load + let completion = runtime.persistence.wait_for_completion().unwrap().unwrap(); + let worker = (catalog != CatalogOutcome::Rejected).then_some(worker); + runtime.apply_session_completion(completion).unwrap(); + // Announced at commit, before the catalog work. + assert_eq!(runtime.committed_targets.first(), Some(&committed)); + + if let Some(worker) = worker { + worker.complete_next(); + runtime.receive(); + } + catalog.assert_terminal_report(&runtime, &target, temp.path()); + assert!( + runtime + .committed_targets + .iter() + .all(|notice| *notice == committed), + "{catalog:?}: {:?}", + runtime.committed_targets + ); + } +} + +#[test] +fn committed_save_as_announces_its_target_before_the_terminal_report() { + let temp = crate::test_temp::tempdir().unwrap(); + let current = named_options(temp.path(), "current"); + let target = named_options(temp.path(), "target"); + let mut input = test_input_state(); + add_line(&mut input, 51); + let mut session = SessionState::new(Some(current)); + let measurer = TextMeasurer::default(); + let (persistence, worker) = PersistenceController::controlled_for_test(); + let mut runtime = CommandRuntime::new(&mut input, &measurer, &mut session, persistence); + + start_session_command( + &mut runtime, + SessionCommand::SaveAs( + target.session_file_path(), + stored_session::SaveAsOverwrite::Deny, + ), + ) + .unwrap(); + worker.complete_next(); // overwrite preflight + runtime.receive(); + assert!(runtime.committed_targets.is_empty()); + worker.complete_next(); // save as + runtime.receive(); + + assert!(runtime.errors.is_empty()); + assert!(matches!( + runtime.reports.as_slice(), + [SessionCommandReport::SaveAs(_)] + )); + assert_eq!( + runtime.committed_targets, + [(Some(target.target.clone()), 0)] + ); +} + +#[test] +fn failed_open_and_save_as_announce_nothing() { + let temp = crate::test_temp::tempdir().unwrap(); + let current = named_options(temp.path(), "current"); + let existing = named_options(temp.path(), "existing"); + stored_session::save_snapshot(&sample_snapshot(), &existing).unwrap(); + let missing = temp.path().join("missing.wayscriber-session"); + for command in [ + SessionCommand::Open(missing), + SessionCommand::SaveAs( + existing.session_file_path(), + stored_session::SaveAsOverwrite::Deny, + ), + ] { + let mut input = test_input_state(); + add_line(&mut input, 51); + let mut session = SessionState::new(Some(current.clone())); + let measurer = TextMeasurer::default(); + let (persistence, worker) = PersistenceController::controlled_for_test(); + let mut runtime = CommandRuntime::new(&mut input, &measurer, &mut session, persistence); + + start_session_command(&mut runtime, command).unwrap(); + worker.complete_next(); // preflight + runtime.receive(); + + assert_eq!(runtime.errors.len(), 1); + assert!(runtime.reports.is_empty()); + assert!(runtime.committed_targets.is_empty()); + assert_eq!(runtime.session.options().unwrap().target, current.target); + } +} + #[test] fn clear_completion_refreshes_consumer_seeds() { let temp = crate::test_temp::tempdir().unwrap(); diff --git a/src/backend/wayland/session/home.rs b/src/backend/wayland/session/home.rs new file mode 100644 index 00000000..42da452f --- /dev/null +++ b/src/backend/wayland/session/home.rs @@ -0,0 +1,164 @@ +//! The overlay's home session and the session inputs its launch carries. +//! +//! An overlay the daemon launches returns home to the daemon's startup session +//! file, or to the configured default session when the daemon has none. The +//! daemon may also pass a remembered session to continue in place of home. +//! Neither input is a command-line flag, so an older overlay ignores both and +//! keeps starting in the session its `--session-file` names. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use crate::daemon::protocol_v2::ReportedSession; +use crate::env_vars::{ + OVERLAY_HOME_SESSION_ENV, OVERLAY_PREFERRED_SESSION_ENV, OVERLAY_SESSION_REPORTS_ENV, +}; +use crate::session::catalog::session_paths_match; +use crate::session::{SessionOptions, SessionTarget}; +use crate::ui::toolbar::session_format::session_display_name; + +/// The session an overlay returns home to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::backend::wayland) enum HomeSession { + /// The configured default session. + Default, + /// A session file the daemon started with. + Named(PathBuf), +} + +impl HomeSession { + pub(in crate::backend::wayland) fn file(&self) -> Option<&Path> { + match self { + Self::Default => None, + Self::Named(path) => Some(path), + } + } +} + +/// The session inputs one overlay launch carries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::backend::wayland) struct SessionLaunch { + pub(in crate::backend::wayland) home: HomeSession, + /// A remembered session to start in instead of home, if it still exists. + pub(in crate::backend::wayland) preferred: Option, +} + +impl SessionLaunch { + /// The inputs for an overlay launched with `startup_file` as its + /// `--session-file`. + pub(in crate::backend::wayland) fn from_environment(startup_file: Option<&Path>) -> Self { + Self::from_lookup(startup_file, |name| std::env::var_os(name)) + } + + fn from_lookup(startup_file: Option<&Path>, lookup: impl Fn(&str) -> Option) -> Self { + let present = |name| { + lookup(name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + }; + // Without a daemon that reads reports, this is a standalone overlay or + // one an older daemon launched: home is what it started in. + if lookup(OVERLAY_SESSION_REPORTS_ENV).is_none_or(|value| value != "1") { + return Self { + home: startup_file.map_or(HomeSession::Default, |path| { + HomeSession::Named(path.to_path_buf()) + }), + preferred: None, + }; + } + + let home = + present(OVERLAY_HOME_SESSION_ENV).map_or(HomeSession::Default, HomeSession::Named); + // A `--session-file` other than home was asked for explicitly, and an + // explicit file outranks the remembered one. + let preferred = + present(OVERLAY_PREFERRED_SESSION_ENV).filter(|_| startup_file == home.file()); + + Self { home, preferred } + } +} + +/// What the running overlay keeps about its home session. +#[derive(Debug)] +pub(in crate::backend::wayland) struct SessionHome { + home: HomeSession, + /// Home's session options before an output identity is applied; `None` + /// when home has persistence disabled. + options: Option, + /// The preferred session, until the first load checks that it still exists. + unchecked_preferred: Option, + /// The target the daemon last learned this overlay is in, by launching it + /// there or from a report. + reported: SessionTarget, +} + +impl SessionHome { + /// `startup` is the target this run starts in. + pub(in crate::backend::wayland) fn new( + launch: SessionLaunch, + options: Option, + startup: SessionTarget, + ) -> Self { + Self { + home: launch.home, + options, + unchecked_preferred: launch.preferred, + reported: startup, + } + } + + pub(in crate::backend::wayland) fn options(&self) -> Option<&SessionOptions> { + self.options.as_ref() + } + + /// The preferred session this run started in, once: only the first load + /// checks it. + pub(in crate::backend::wayland) fn take_unchecked_preferred(&mut self) -> Option { + self.unchecked_preferred.take() + } + + /// How the Session menu and notices name home. + pub(in crate::backend::wayland) fn label(&self) -> String { + match &self.home { + HomeSession::Default => "the default session".to_owned(), + HomeSession::Named(path) => session_display_name(path), + } + } + + /// What to report now that the overlay is in `target`, or `None` when the + /// daemon already knows. Home is reported as such even when it is a named + /// file, so the daemon never remembers home as a session of its own. + pub(in crate::backend::wayland) fn report_for( + &mut self, + target: SessionTarget, + ) -> Option { + if target == self.reported { + return None; + } + let report = match &target { + SessionTarget::NamedFile(path) if !self.is_home_file(path) => { + ReportedSession::Named(path.clone()) + } + _ => ReportedSession::Home, + }; + self.reported = target; + Some(report) + } + + fn is_home_file(&self, path: &Path) -> bool { + self.home + .file() + .is_some_and(|home| session_paths_match(home, path)) + } +} + +/// The target a run with `options` is in: a run without persistence is in the +/// configured default session all the same. +pub(in crate::backend::wayland) fn session_target( + options: Option<&SessionOptions>, +) -> SessionTarget { + options.map_or(SessionTarget::Configured, |options| options.target.clone()) +} + +#[cfg(test)] +mod tests; diff --git a/src/backend/wayland/session/home/tests.rs b/src/backend/wayland/session/home/tests.rs new file mode 100644 index 00000000..7fb3c99e --- /dev/null +++ b/src/backend/wayland/session/home/tests.rs @@ -0,0 +1,171 @@ +use std::collections::HashMap; + +use super::*; + +const HOME: &str = "/sessions/home.wayscriber-session"; +const PREFERRED: &str = "/sessions/b.wayscriber-session"; + +fn launch(startup_file: Option<&str>, environment: &[(&str, &str)]) -> SessionLaunch { + let environment: HashMap<_, _> = environment.iter().copied().collect(); + SessionLaunch::from_lookup(startup_file.map(Path::new), |name| { + environment.get(name).map(OsString::from) + }) +} + +fn named(path: &str) -> HomeSession { + HomeSession::Named(PathBuf::from(path)) +} + +#[test] +fn a_daemon_launch_carries_home_and_the_remembered_session() { + let daemon = [ + (OVERLAY_SESSION_REPORTS_ENV, "1"), + (OVERLAY_HOME_SESSION_ENV, HOME), + (OVERLAY_PREFERRED_SESSION_ENV, PREFERRED), + ]; + + assert_eq!( + launch(Some(HOME), &daemon), + SessionLaunch { + home: named(HOME), + preferred: Some(PathBuf::from(PREFERRED)), + } + ); +} + +#[test] +fn without_a_startup_file_home_is_the_default_session() { + let daemon = [ + (OVERLAY_SESSION_REPORTS_ENV, "1"), + (OVERLAY_PREFERRED_SESSION_ENV, PREFERRED), + ]; + + assert_eq!( + launch(None, &daemon), + SessionLaunch { + home: HomeSession::Default, + preferred: Some(PathBuf::from(PREFERRED)), + } + ); +} + +#[test] +fn an_explicit_session_file_outranks_the_remembered_one() { + let explicit = "/sessions/c.wayscriber-session"; + for home in [None, Some(HOME)] { + let mut daemon = vec![ + (OVERLAY_SESSION_REPORTS_ENV, "1"), + (OVERLAY_PREFERRED_SESSION_ENV, PREFERRED), + ]; + daemon.extend(home.map(|home| (OVERLAY_HOME_SESSION_ENV, home))); + + let launch = launch(Some(explicit), &daemon); + + assert_eq!(launch.home, home.map_or(HomeSession::Default, named)); + assert_eq!(launch.preferred, None, "home {home:?}"); + } +} + +#[test] +fn without_a_daemon_that_reads_reports_home_is_the_startup_session() { + let inputs = [ + (OVERLAY_HOME_SESSION_ENV, HOME), + (OVERLAY_PREFERRED_SESSION_ENV, PREFERRED), + ]; + for marker in [None, Some("0")] { + let mut environment = inputs.to_vec(); + environment.extend(marker.map(|marker| (OVERLAY_SESSION_REPORTS_ENV, marker))); + let startup = "/sessions/startup.wayscriber-session"; + + assert_eq!( + launch(Some(startup), &environment), + SessionLaunch { + home: named(startup), + preferred: None, + } + ); + assert_eq!( + launch(None, &environment), + SessionLaunch { + home: HomeSession::Default, + preferred: None, + } + ); + } +} + +fn home_session(home: HomeSession, preferred: Option<&str>, startup: SessionTarget) -> SessionHome { + SessionHome::new( + SessionLaunch { + home, + preferred: preferred.map(PathBuf::from), + }, + None, + startup, + ) +} + +fn file(path: &str) -> SessionTarget { + SessionTarget::NamedFile(PathBuf::from(path)) +} + +#[test] +fn home_is_named_like_any_other_session() { + let named = home_session(named(HOME), None, file(HOME)); + let default = home_session(HomeSession::Default, None, SessionTarget::Configured); + + assert_eq!(named.label(), "home.wayscriber-session"); + assert_eq!(default.label(), "the default session"); +} + +#[test] +fn only_the_first_load_checks_the_preferred_session() { + let mut home = home_session(HomeSession::Default, Some(PREFERRED), file(PREFERRED)); + + assert_eq!( + home.take_unchecked_preferred(), + Some(PathBuf::from(PREFERRED)) + ); + assert_eq!(home.take_unchecked_preferred(), None); +} + +#[test] +fn only_a_changed_session_is_reported() { + let mut home = home_session(named(HOME), Some(PREFERRED), file(PREFERRED)); + + assert_eq!(home.report_for(file(PREFERRED)), None); + + let other = "/sessions/d.wayscriber-session"; + assert_eq!( + home.report_for(file(other)), + Some(ReportedSession::Named(PathBuf::from(other))) + ); + assert_eq!(home.report_for(file(other)), None); +} + +#[test] +fn home_is_reported_as_home_even_as_a_named_file() { + let mut named_home = home_session(named(HOME), None, file(PREFERRED)); + // Another spelling of the same file is still home. + let alias = "/sessions/./home.wayscriber-session"; + + assert_eq!( + named_home.report_for(file(alias)), + Some(ReportedSession::Home) + ); + + let mut default_home = home_session(HomeSession::Default, None, file(PREFERRED)); + assert_eq!( + default_home.report_for(SessionTarget::Configured), + Some(ReportedSession::Home) + ); + assert_eq!( + default_home.report_for(file(HOME)), + Some(ReportedSession::Named(PathBuf::from(HOME))) + ); +} + +#[test] +fn a_run_without_persistence_is_in_the_default_session() { + assert_eq!(session_target(None), SessionTarget::Configured); +} diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index 42fa36d5..e3023dd6 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -138,6 +138,7 @@ pub(in crate::backend::wayland) struct WaylandStateInit { pub palette_recents: crate::palette_recents::PaletteRecentsWriter, pub capture_manager: CaptureManager, pub session_options: Option, + pub session_home: crate::backend::wayland::session::SessionHome, pub session_config_failed: bool, pub persistence: crate::backend::wayland::session::PersistenceController, pub runtime_ui: Option, @@ -235,6 +236,7 @@ pub(super) struct WaylandState { // Session persistence pub(super) session: SessionState, + pub(super) session_home: crate::backend::wayland::session::SessionHome, pub(super) persistence: crate::backend::wayland::session::PersistenceController, pub(super) session_transaction: Option, diff --git a/src/backend/wayland/state/core/init.rs b/src/backend/wayland/state/core/init.rs index f2441dfd..7c35f1bd 100644 --- a/src/backend/wayland/state/core/init.rs +++ b/src/backend/wayland/state/core/init.rs @@ -15,6 +15,7 @@ impl WaylandState { palette_recents, capture_manager, session_options, + session_home, session_config_failed, persistence, runtime_ui, @@ -157,6 +158,7 @@ impl WaylandState { #[cfg(feature = "tablet-input")] tablet: super::super::tablet_runtime::TabletState::new(tablet_manager, tablet_settings), session: SessionState::new(session_options), + session_home, session_config_failed, session_transaction: None, persistence, diff --git a/src/backend/wayland/state/core/mod.rs b/src/backend/wayland/state/core/mod.rs index 14e1f1c0..a14e2fd5 100644 --- a/src/backend/wayland/state/core/mod.rs +++ b/src/backend/wayland/state/core/mod.rs @@ -3,3 +3,4 @@ mod init; mod output; pub(in crate::backend::wayland::state) mod overlay; mod session; +mod session_home; diff --git a/src/backend/wayland/state/core/output/transition.rs b/src/backend/wayland/state/core/output/transition.rs index 41e6b27a..3f13b116 100644 --- a/src/backend/wayland/state/core/output/transition.rs +++ b/src/backend/wayland/state/core/output/transition.rs @@ -6,6 +6,7 @@ impl WaylandState { physical_output_identity: Option, reason: &str, ) { + self.start_at_home_if_preferred_session_is_gone(); let Some(mut staged_options) = self.session_options().cloned() else { return; }; diff --git a/src/backend/wayland/state/core/session.rs b/src/backend/wayland/state/core/session.rs index 954dd642..6b35765e 100644 --- a/src/backend/wayland/state/core/session.rs +++ b/src/backend/wayland/state/core/session.rs @@ -37,6 +37,10 @@ impl SessionCommandRuntime for WaylandState { self.refresh_runtime_ui_config_seeds(); } + fn session_target_committed(&mut self) { + WaylandState::session_target_committed(self); + } + fn finish_session_command(&mut self, report: SessionCommandReport) { WaylandState::finish_session_command(self, report); } diff --git a/src/backend/wayland/state/core/session_home.rs b/src/backend/wayland/state/core/session_home.rs new file mode 100644 index 00000000..5d3f00d6 --- /dev/null +++ b/src/backend/wayland/state/core/session_home.rs @@ -0,0 +1,82 @@ +use log::{info, warn}; + +use super::super::*; +use crate::backend::wayland::backend::event_loop::session_save; +use crate::backend::wayland::session::{PersistenceOperation, PersistenceOutcome, session_target}; +use crate::daemon::protocol_v2::publish_session_from_environment; +use crate::input::state::{Toast, ToastPriority}; +use crate::ui::toolbar::session_format::session_display_name; + +impl WaylandState { + /// Starts at home instead of the remembered session this run was asked to + /// continue when that session no longer exists: it was moved or deleted + /// after the daemon chose it. The daemon hears that this overlay is home, + /// so the next show does not try the missing session again. + pub(in crate::backend::wayland) fn start_at_home_if_preferred_session_is_gone(&mut self) { + let Some(preferred) = self.session_home.take_unchecked_preferred() else { + return; + }; + let Some(options) = self.session_options().cloned() else { + return; + }; + match session_save::run_persistence_operation( + self, + PersistenceOperation::HasArtifacts { options }, + ) { + Ok(PersistenceOutcome::HasArtifacts(true)) => return, + Ok(PersistenceOutcome::HasArtifacts(false)) => {} + Ok(other) => { + warn!("Unexpected outcome checking the remembered session: {other:?}"); + return; + } + // The load that follows reports the failure the way it would for + // a session file given at startup. + Err(error) => { + warn!( + "Failed to check remembered session {}: {error:#}", + preferred.display() + ); + return; + } + } + + info!( + "Remembered session {} no longer exists; starting at home", + preferred.display() + ); + let home = self.session_home.options().cloned(); + self.session.replace_options_before_load(home.clone()); + self.input_state.set_session_preflight_options(home); + self.input_state.push_toast( + ToastPriority::Info, + "session", + Toast::warning(format!( + "Session {} is no longer available; opened {}", + session_display_name(&preferred), + self.session_home.label() + )), + ); + self.session_target_committed(); + } + + /// Tells the daemon that launched this overlay the session it is now in, + /// if that changed, so the next show starts there. A failure leaves the + /// daemon with what it knew before, and only that is reported: the session + /// switch stands. + pub(in crate::backend::wayland) fn session_target_committed(&mut self) { + let target = session_target(self.session.options()); + let Some(session) = self.session_home.report_for(target) else { + return; + }; + if let Err(error) = publish_session_from_environment(&session) { + warn!("Failed to report the session to the daemon: {error:#}"); + self.input_state.push_toast( + ToastPriority::Info, + "session.report", + Toast::warning(format!( + "The overlay may not reopen in this session after it hides: {error:#}" + )), + ); + } + } +} diff --git a/src/backend/wayland/state/toolbar/events/session.rs b/src/backend/wayland/state/toolbar/events/session.rs index b459ed3a..ef2f9e16 100644 --- a/src/backend/wayland/state/toolbar/events/session.rs +++ b/src/backend/wayland/state/toolbar/events/session.rs @@ -2,6 +2,7 @@ use super::*; use crate::backend::wayland::session::{SessionCommand, SessionCommandReport}; use crate::input::state::{Toast, ToastPriority}; use crate::session::catalog; +use crate::ui::toolbar::session_format::session_display_name; use anyhow::{Context, Error as AnyhowError, Result, anyhow}; use std::path::{Path, PathBuf}; use wayland_client::{Connection, QueueHandle}; @@ -22,13 +23,6 @@ pub(super) fn populate_session_snapshot( snapshot.active_session_path = active_path; } -fn session_display_name(path: &Path) -> String { - path.file_name() - .and_then(|name| name.to_str()) - .map(str::to_string) - .unwrap_or_else(|| path.display().to_string()) -} - pub(super) fn session_info_summary(inspection: &crate::session::SessionInspection) -> String { let name = session_display_name(&inspection.session_path); if !inspection.exists { diff --git a/src/daemon/protocol_v2/mod.rs b/src/daemon/protocol_v2/mod.rs index 83770e9b..19b8d34b 100644 --- a/src/daemon/protocol_v2/mod.rs +++ b/src/daemon/protocol_v2/mod.rs @@ -5,6 +5,7 @@ mod digest; mod linux; mod mode; mod runtime; +mod session_target; mod wire; pub(crate) use child::OverlayChildOwner; @@ -24,6 +25,7 @@ pub(crate) use linux::{ }; pub(crate) use mode::DaemonControlProtocolMode; pub(crate) use runtime::{ClassifiedRuntimeRecord, read_runtime_record, write_runtime_record_v2}; +pub(crate) use session_target::{ReportedSession, publish_session_from_environment}; pub(crate) use wire::EffectKind; pub(crate) use wire::{DaemonRequestV2, DaemonRuntimeRecordV2}; diff --git a/src/daemon/protocol_v2/session_target.rs b/src/daemon/protocol_v2/session_target.rs new file mode 100644 index 00000000..11c0835c --- /dev/null +++ b/src/daemon/protocol_v2/session_target.rs @@ -0,0 +1,131 @@ +//! The session an overlay child is in, reported to the daemon that owns it. +//! +//! A daemon overlay writes `.target` when its session changes, so +//! the daemon can start the next overlay in that session. Reports live in +//! `daemon-commands/overlay-targets/`, beside the strict v2 tree rather than in +//! it: the v2 layout check and child-proof recovery reject entries they do not +//! know, and an older daemon reads only the plain files directly in +//! `daemon-commands/`. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; + +use super::child::{ActiveGeneration, active_generation_from_environment}; + +const REPORT_SCHEMA: u16 = 1; +/// Room for the longest path Linux accepts, even with every byte escaped. +const MAX_REPORT_BYTES: usize = 32 * 1024; + +/// The session an overlay reports being in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ReportedSession { + /// The daemon's home session: its startup session file, or the configured + /// default session when it has none. + Home, + /// Another session file. + Named(PathBuf), +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SessionTargetRecord { + schema: u16, + generation: String, + pid: u32, + process_start_ticks: u64, + /// `None` is home. + target: Option, +} + +fn report_dir() -> PathBuf { + crate::paths::daemon_command_dir().join("overlay-targets") +} + +fn report_path(generation: &str) -> PathBuf { + report_dir().join(format!("{generation}.target")) +} + +/// Reports `session` to the daemon that launched this overlay, replacing any +/// earlier report. Returns whether a report was written: a standalone overlay, +/// or one an older daemon launched, has no daemon that reads reports. +pub(crate) fn publish_session_from_environment(session: &ReportedSession) -> Result { + if std::env::var_os(crate::env_vars::OVERLAY_SESSION_REPORTS_ENV) + .is_none_or(|value| value != "1") + { + return Ok(false); + } + let generation = std::env::var(crate::env_vars::OVERLAY_CHILD_GENERATION_ENV) + .context("a daemon overlay reports its session under its generation")?; + super::wire::validate_id(&generation)?; + // Only the process that published this generation's identity reports for it. + if matches!( + active_generation_from_environment()?, + ActiveGeneration::Inactive + ) { + bail!("this overlay has not published its daemon child identity"); + } + + let target = match session { + ReportedSession::Home => None, + ReportedSession::Named(path) => Some(report_path_text(path)?), + }; + let record = SessionTargetRecord { + schema: REPORT_SCHEMA, + generation, + pid: std::process::id(), + process_start_ticks: super::linux::current_process_start_ticks()?, + target, + }; + let bytes = super::wire::canonical_json(&record, MAX_REPORT_BYTES)?; + create_report_dir()?; + crate::durable_io::write_atomic( + &report_path(&record.generation), + &bytes, + crate::durable_io::AtomicWriteOptions::private_runtime_file(), + )?; + + Ok(true) +} + +/// `path` as the absolute UTF-8 text a report carries. +fn report_path_text(path: &Path) -> Result { + let path = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .context("failed to resolve a relative session path")? + .join(path) + }; + path.into_os_string().into_string().map_err(|path| { + anyhow!( + "session path {} is not UTF-8", + PathBuf::from(path).display() + ) + }) +} + +fn create_report_dir() -> Result<()> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let directory = report_dir(); + match std::fs::create_dir(&directory) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error).with_context(|| format!("failed to create {}", directory.display())); + } + } + let metadata = std::fs::symlink_metadata(&directory)?; + // SAFETY: geteuid has no preconditions and cannot fail. + let owner = unsafe { libc::geteuid() }; + if !metadata.is_dir() || metadata.uid() != owner { + bail!("{} is not a private report directory", directory.display()); + } + std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/src/daemon/protocol_v2/session_target/tests.rs b/src/daemon/protocol_v2/session_target/tests.rs new file mode 100644 index 00000000..e7bf2698 --- /dev/null +++ b/src/daemon/protocol_v2/session_target/tests.rs @@ -0,0 +1,74 @@ +use std::ffi::OsStr; + +use super::*; +use crate::env_vars::{ + OVERLAY_CHILD_GENERATION_ENV, OVERLAY_SESSION_REPORTS_ENV, XDG_RUNTIME_DIR_ENV, +}; + +/// Runs `body` as the overlay child `generation` of a daemon that reads +/// session reports when `reports` is set, in a private runtime directory. +fn as_daemon_overlay(reports: Option<&str>, body: impl FnOnce(&str)) { + let runtime = crate::test_temp::tempdir().unwrap(); + let generation = super::super::ProtocolId::generate().unwrap().to_string(); + + crate::test_env::with_env_vars( + &[ + (XDG_RUNTIME_DIR_ENV, Some(runtime.path().as_os_str())), + (OVERLAY_CHILD_GENERATION_ENV, Some(OsStr::new(&generation))), + (OVERLAY_SESSION_REPORTS_ENV, reports.map(OsStr::new)), + ], + || body(&generation), + ); +} + +fn read_record(generation: &str) -> SessionTargetRecord { + let bytes = std::fs::read(report_path(generation)).unwrap(); + super::super::wire::parse_canonical_json(&bytes, MAX_REPORT_BYTES).unwrap() +} + +#[test] +fn a_daemon_overlay_reports_its_session_under_its_own_identity() { + as_daemon_overlay(Some("1"), |generation| { + super::super::publish_ready_from_environment().unwrap(); + + let named = ReportedSession::Named(PathBuf::from("lectures/b.wayscriber-session")); + assert!(publish_session_from_environment(&named).unwrap()); + + let record = read_record(generation); + assert_eq!(record.schema, REPORT_SCHEMA); + assert_eq!(record.generation, generation); + assert_eq!(record.pid, std::process::id()); + assert_eq!( + record.process_start_ticks, + super::super::linux::current_process_start_ticks().unwrap() + ); + let expected = std::env::current_dir() + .unwrap() + .join("lectures/b.wayscriber-session"); + assert_eq!(record.target.as_deref(), expected.to_str()); + + // A later report replaces the earlier one; home carries no path. + assert!(publish_session_from_environment(&ReportedSession::Home).unwrap()); + assert_eq!(read_record(generation).target, None); + }); +} + +#[test] +fn only_a_daemon_that_reads_reports_receives_one() { + for marker in [None, Some("0")] { + as_daemon_overlay(marker, |generation| { + super::super::publish_ready_from_environment().unwrap(); + + assert!(!publish_session_from_environment(&ReportedSession::Home).unwrap()); + assert!(!report_path(generation).exists()); + }); + } +} + +#[test] +fn an_overlay_without_its_child_identity_cannot_report() { + as_daemon_overlay(Some("1"), |generation| { + assert!(publish_session_from_environment(&ReportedSession::Home).is_err()); + assert!(!report_path(generation).exists()); + }); +} diff --git a/src/env_vars.rs b/src/env_vars.rs index a3720cb2..12201030 100644 --- a/src/env_vars.rs +++ b/src/env_vars.rs @@ -12,6 +12,14 @@ pub const NO_DETACH_ENV: &str = "WAYSCRIBER_NO_DETACH"; pub const NO_TRAY_ENV: &str = "WAYSCRIBER_NO_TRAY"; pub(crate) const OVERLAY_CHILD_GENERATION_ENV: &str = "WAYSCRIBER_OVERLAY_CHILD_GENERATION"; pub(crate) const DAEMON_WATCHDOG_FD_ENV: &str = "WAYSCRIBER_INTERNAL_DAEMON_WATCHDOG_FD"; +/// Set to `1` by a daemon that reads the session its overlay child reports. +pub(crate) const OVERLAY_SESSION_REPORTS_ENV: &str = "WAYSCRIBER_OVERLAY_SESSION_REPORTS"; +/// The daemon's startup session file, which its overlays return home to. +/// Absent, home is the configured default session. +pub(crate) const OVERLAY_HOME_SESSION_ENV: &str = "WAYSCRIBER_OVERLAY_HOME_SESSION"; +/// The session file a daemon overlay continues in place of home, if it still +/// exists. +pub(crate) const OVERLAY_PREFERRED_SESSION_ENV: &str = "WAYSCRIBER_OVERLAY_PREFERRED_SESSION"; pub const CATALOG_HOOKS_TEST_ENV: &str = "WAYSCRIBER_ENABLE_CATALOG_HOOKS_IN_TESTS"; /// Disables the periodic update check regardless of `[updates] check`. pub const DISABLE_UPDATE_CHECK_ENV: &str = "WAYSCRIBER_DISABLE_UPDATE_CHECK"; diff --git a/src/ui/toolbar/session_format.rs b/src/ui/toolbar/session_format.rs index b0db907e..0a6937af 100644 --- a/src/ui/toolbar/session_format.rs +++ b/src/ui/toolbar/session_format.rs @@ -4,6 +4,17 @@ //! popover also uses the fixed character-count truncators below, while GTK //! delegates visible ellipsization to Pango. +use std::path::Path; + +/// How a session file is named to the user: its file name, or the whole path +/// when it has none that is UTF-8. +pub fn session_display_name(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .map(str::to_string) + .unwrap_or_else(|| path.display().to_string()) +} + /// Middle-ellipsize so both the head and the distinguishing tail survive. /// Tail truncation made e.g. two different "lecture-05-…" files render /// identically in the recents list. From f4109dd5bae708f2aed4e5124041fab5ede1f514 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:07:55 +0200 Subject: [PATCH 05/14] feat(daemon): continue the overlay's last session after hide and show The daemon now remembers the session its overlay last reported and starts the next overlay there. The remembered session is two-valued, home or a named file, lives only in daemon memory, and is resolved when the child is spawned, so a report read after a request was queued still applies. The child owner records the start identity proved at readiness and reads the child's final report inside retirement, before it releases that identity, on natural exit, on stop and on forced reap. A report must be a private regular file carrying exactly that generation, PID and start time; anything else is logged and ignored, and the report is removed either way. Startup removes reports an earlier daemon left without restoring anything from them. Launches carry optional variables: a capability marker, the daemon's home session file, and the remembered session as the one to continue. The command line is unchanged, so an older overlay still starts at home. A --daemon-toggle --session-file request outranks the remembered session for its activation; a request for home itself starts at home and forgets the remembered session. The visible-target guard uses the session the running overlay last reported. The broker allows the new variables and strips them from helpers that are not wayscriber relaunches. --- README.md | 2 +- docs/CONFIG.md | 6 +- src/daemon/AGENTS.md | 1 + src/daemon/core.rs | 7 + src/daemon/core/tests.rs | 29 ++++ src/daemon/core/toggles.rs | 16 +- src/daemon/overlay/launch.rs | 96 ++++++++++- src/daemon/overlay/lifecycle.rs | 9 +- src/daemon/overlay/lifecycle/stop.rs | 41 +++-- src/daemon/overlay/mod.rs | 29 +++- src/daemon/overlay/process.rs | 4 +- src/daemon/overlay/spawn.rs | 3 +- src/daemon/overlay/tests.rs | 159 ++++++++++++++++- src/daemon/overlay/tests/fake_overlay.rs | 56 +++++- src/daemon/protocol_v2/child.rs | 76 ++++++-- src/daemon/protocol_v2/mod.rs | 4 +- src/daemon/protocol_v2/session_target.rs | 163 +++++++++++++++++- .../protocol_v2/session_target/tests.rs | 143 +++++++++++++++ src/process_broker/manifest.rs | 8 +- 19 files changed, 782 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 10cd9510..17f1002e 100644 --- a/README.md +++ b/README.md @@ -1211,7 +1211,7 @@ See [Session manager examples](examples/session-manager.md) for complete CLI, ov - When a fresh launch restores ink onto the transparent overlay board, a toast such as "Restored 7 annotations from last session" offers **Clear** (undoable, like Clear Canvas), because that ink now sits over whatever is on screen. Daemon toggles, empty restores, and solid boards stay quiet. - Config values seed startup defaults. When `restore_tool_state` is enabled (default), the last-used tool settings saved in the session (including arrow head placement and the starting Spotlight magnification) override those config defaults on startup. Run `wayscriber --clear-tool-state` to remove only that saved tool layer so config defaults apply next startup while saved boards/history remain. In a running overlay, use Command Palette → Reset Tool Defaults to clear the saved layer and immediately apply config defaults to the active tools. -- `--session-file` uses exactly the selected file, implies persistence for that overlay run, rejects directories/symlinks/special files, and does not create missing parent directories. A running daemon can launch a hidden overlay with a named target; if the overlay is already visible, hide it before switching to a different named session. +- `--session-file` uses exactly the selected file, implies persistence for that overlay run, rejects directories/symlinks/special files, and does not create missing parent directories. A running daemon can launch a hidden overlay with a named target; if the overlay is already visible, hide it before switching to a different named session. The daemon's overlay keeps the session it last opened or saved as across hide and show, while the daemon runs. - The overlay Session controls live in the top toolbar's Session popover (overflow menu → Session...). They can open an existing named session, save the current overlay as another named session, show session info, clear the active session, reopen recent named sessions, and jump to the configurator. The Open/Save As dialogs use `zenity` or `kdialog`; Save As appends `.wayscriber-session` when no extension is supplied and asks before replacing existing session artifacts. - The configurator Session tab manages recent named sessions recorded when named-session targets are opened or saved from the CLI, daemon, or overlay. It can rename catalog labels, reveal files, and forget metadata without touching files. Clear Tool State removes only the saved tool layer; Clear Saved Data removes session files. Duplicate, Move, Clear Tool State, and Clear are disabled while an overlay, manually started daemon, or background service is active. diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 0acbaff4..ccb1843a 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -2111,8 +2111,10 @@ Use the CLI helpers for quick maintenance: - `wayscriber --clear-tool-state` removes only the saved tool defaults from the session snapshot, preserving saved boards and history. - `wayscriber --active --session-file ~/Documents/lecture-04.wayscriber-session` opens and saves a named session file directly. - `wayscriber --freeze --session-file ~/Documents/lecture-04.wayscriber-session` starts frozen mode with that same named session target. -- `wayscriber --daemon --session-file ~/Documents/lecture-04.wayscriber-session` starts a daemon whose overlay activations use that named session target. -- `wayscriber --daemon-toggle --session-file ~/Documents/meeting.wayscriber-session` asks the running daemon to launch a hidden overlay with that named session target. If the overlay is already visible with a different target, hide it before switching. +- `wayscriber --daemon --session-file ~/Documents/lecture-04.wayscriber-session` starts a daemon whose overlay activations use that named session target, the daemon's home session. +- `wayscriber --daemon-toggle --session-file ~/Documents/meeting.wayscriber-session` asks the running daemon to launch a hidden overlay with that named session target. It applies to that activation only. If the overlay is already visible with a different target, hide it before switching. + +The daemon's overlay keeps its session across hide and show. After **Open** or **Save As** switches the overlay to another session, the next activation continues in that session, until the overlay returns to the daemon's home session: the file given with `--daemon --session-file`, or the configured default session without one. A `--daemon-toggle --session-file` request takes precedence for its activation. If the remembered file was moved or deleted, the overlay opens the home session instead and says so. The daemon keeps this only while it runs; a restarted daemon starts at home. - `wayscriber --session-info --session-file `, `wayscriber --clear-session --session-file `, and `wayscriber --clear-tool-state --session-file ` target only that named file. Config values seed startup defaults. When `restore_tool_state = true`, the saved session tool state is applied after those defaults, so edits such as `[arrow] head_at_end = true` can appear ignored if the session snapshot still stores an older arrow setting. Run `wayscriber --clear-tool-state` (or add `--session-file ` for a named session) to make config defaults apply on the next startup without deleting saved boards. In a running overlay, Command Palette -> Reset Tool Defaults clears the saved layer for the active session and immediately applies config defaults to the current tools so the next autosave keeps those defaults. diff --git a/src/daemon/AGENTS.md b/src/daemon/AGENTS.md index 1cdda13b..d92593b9 100644 --- a/src/daemon/AGENTS.md +++ b/src/daemon/AGENTS.md @@ -8,6 +8,7 @@ - `binary_conflict.rs` warns when another Wayscriber binary exists besides the running daemon. - `overlay/launch.rs` owns resolved launch options and activation tokens; `overlay/mod.rs` queues and consumes them, preserving token-only retention on existing early-error paths. - `overlay/lifecycle.rs` owns overlay visibility, active target/flag, backoff, start and retirement; `overlay/lifecycle/stop.rs` owns graceful/forced shutdown. `protocol_v2::OverlayChildOwner` retains child identity, proof records and reaping. +- `protocol_v2/session_target.rs` owns the session reports overlay children write to `daemon-commands/overlay-targets/`, outside the strict v2 tree. `OverlayChildOwner` reads a child's final report before releasing its identity; the daemon keeps the remembered session in memory only and clears stale reports at startup. - `tray/` owns tray integration and shortcut hint I/O. - `setup.rs` and `global_shortcuts.rs` support daemon setup workflows. - `update_watch.rs` owns the background update notice: it publishes to `TrayStatusShared` diff --git a/src/daemon/core.rs b/src/daemon/core.rs index 7657493e..3a5b2bbb 100644 --- a/src/daemon/core.rs +++ b/src/daemon/core.rs @@ -88,6 +88,9 @@ pub struct Daemon { pub(super) visibility_intents: Arc, pub(super) initial_mode: Option, pub(super) initial_named_session_file: Option, + /// The session the last overlay reported, which the next one continues; + /// `None` is home. It lives only as long as this daemon. + pub(super) remembered_session_file: Option, pub(super) instance_token: String, pub(super) freeze_on_show: bool, pub(super) tray_enabled: bool, @@ -130,6 +133,7 @@ impl Daemon { visibility_intents: Arc::new(VisibilityIntents::default()), initial_mode, initial_named_session_file, + remembered_session_file: None, instance_token: crate::daemon::generate_daemon_instance_token(), freeze_on_show: false, tray_enabled, @@ -223,6 +227,9 @@ impl Daemon { err ); } + if let Err(err) = super::protocol_v2::clear_stale_session_reports() { + warn!("Failed to clear stale overlay session reports on startup: {err:#}"); + } } fn start_tray( diff --git a/src/daemon/core/tests.rs b/src/daemon/core/tests.rs index 48ae0064..d4e86836 100644 --- a/src/daemon/core/tests.rs +++ b/src/daemon/core/tests.rs @@ -183,6 +183,35 @@ fn visible_overlay_rejects_different_named_session_request() { ); } +#[test] +fn visible_overlay_is_in_the_session_it_switched_to() { + let switched = "/tmp/switched.wayscriber-session"; + super::super::overlay::tests::with_reporting_overlay(switched, |daemon| { + let request = |session_file: &str| { + Some(DaemonToggleRequest { + session_file: Some(PathBuf::from(session_file)), + ..Default::default() + }) + }; + + // Launched at home, the overlay has since switched away from it. + let err = daemon + .process_single_toggle(request("/tmp/home.wayscriber-session"), None, false) + .expect_err("the session the overlay left is no longer visible"); + assert!( + format!("{err:#}") + .contains("cannot switch named session target while overlay is visible"), + "{err:#}" + ); + assert_eq!(daemon.test_state(), OverlayState::Visible); + + daemon + .process_single_toggle(request(switched), None, false) + .unwrap(); + assert_eq!(daemon.test_state(), OverlayState::Hidden); + }); +} + #[test] fn visible_overlay_rejection_writes_daemon_toggle_error_response() { let temp = crate::test_temp::tempdir().expect("tempdir"); diff --git a/src/daemon/core/toggles.rs b/src/daemon/core/toggles.rs index 9d4a0041..13c7f4f7 100644 --- a/src/daemon/core/toggles.rs +++ b/src/daemon/core/toggles.rs @@ -3,6 +3,7 @@ use super::super::control::{ write_daemon_toggle_command_error, write_daemon_toggle_command_success, }; use super::super::overlay::overlay_start_backoff_reason; +use super::super::protocol_v2::ReportedSession; use super::super::types::OverlayState; use super::{DUPLICATE_SHORTCUT_SUPPRESSION_WINDOW, Daemon}; use crate::tray_action::TrayAction; @@ -22,11 +23,16 @@ impl Daemon { if self.overlay.state() != OverlayState::Visible { return Ok(()); } - if self - .overlay - .active_named_session_file() - .is_some_and(|active| named_session_paths_match(active, requested)) - { + // The overlay may have switched session since it was launched. + let active = match self.overlay.reported_session() { + Some(ReportedSession::Home) => self.initial_named_session_file.clone(), + Some(ReportedSession::Named(path)) => Some(path), + None => self + .overlay + .active_named_session_file() + .map(Path::to_path_buf), + }; + if active.is_some_and(|active| named_session_paths_match(&active, requested)) { return Ok(()); } diff --git a/src/daemon/overlay/launch.rs b/src/daemon/overlay/launch.rs index d3b3d631..46bb7dae 100644 --- a/src/daemon/overlay/launch.rs +++ b/src/daemon/overlay/launch.rs @@ -2,11 +2,17 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use crate::daemon::control::DaemonToggleRequest; -use crate::env_vars::{DESKTOP_STARTUP_ID_ENV, NO_DETACH_ENV, XDG_ACTIVATION_TOKEN_ENV}; +use crate::env_vars::{ + DESKTOP_STARTUP_ID_ENV, NO_DETACH_ENV, OVERLAY_HOME_SESSION_ENV, OVERLAY_PREFERRED_SESSION_ENV, + OVERLAY_SESSION_REPORTS_ENV, XDG_ACTIVATION_TOKEN_ENV, +}; pub(in crate::daemon) struct OverlayLaunchRequest { mode: Option, - named_session_file: Option, + /// The session file this request asked for, which outranks any other. + explicit_session_file: Option, + /// The daemon's startup session file, its overlays' home. + home_session_file: Option, freeze: bool, exit_after_capture: bool, no_exit_after_capture: bool, @@ -19,7 +25,7 @@ impl OverlayLaunchRequest { request: Option, activation_token: Option, mode: Option<&str>, - named_session_file: Option<&Path>, + home_session_file: Option<&Path>, freeze: bool, ) -> Self { let request = request.unwrap_or_default(); @@ -27,9 +33,8 @@ impl OverlayLaunchRequest { Self { mode: request.mode.or_else(|| mode.map(str::to_owned)), - named_session_file: request - .session_file - .or_else(|| named_session_file.map(Path::to_path_buf)), + explicit_session_file: request.session_file, + home_session_file: home_session_file.map(Path::to_path_buf), freeze: request.freeze || freeze, exit_after_capture: request.exit_after_capture, no_exit_after_capture: request.no_exit_after_capture, @@ -42,8 +47,16 @@ impl OverlayLaunchRequest { self.mode.as_deref() } + /// The session file the overlay is launched with: the requested one, else + /// home. pub(super) fn named_session_file(&self) -> Option<&Path> { - self.named_session_file.as_deref() + self.explicit_session_file + .as_deref() + .or(self.home_session_file.as_deref()) + } + + pub(super) fn explicit_session_file(&self) -> Option<&Path> { + self.explicit_session_file.as_deref() } pub(super) fn activation_token(&self) -> Option<&str> { @@ -65,10 +78,14 @@ pub(super) struct OverlayLaunch { pub(super) environment: Vec<(OsString, Option)>, } +/// The launch for `request`. A child `generation` reports its session to the +/// daemon, which passes the session it remembers from the last report as the +/// one to continue, unless the request asked for a session file. pub(super) fn build_overlay_launch( request: &OverlayLaunchRequest, resume_default: Option, generation: Option<&str>, + remembered_session_file: Option<&Path>, ) -> OverlayLaunch { let mut arguments = vec![OsString::from("--active")]; if request.freeze { @@ -101,6 +118,23 @@ pub(super) fn build_overlay_launch( .session_resume_override(resume_default) .map(|enabled| if enabled { "on".into() } else { "off".into() }), )); + // Optional inputs, so an older overlay ignores them and starts as before. + environment.extend([ + ( + OVERLAY_SESSION_REPORTS_ENV.into(), + generation.map(|_| "1".into()), + ), + ( + OVERLAY_HOME_SESSION_ENV.into(), + request.home_session_file.as_deref().map(Into::into), + ), + ( + OVERLAY_PREFERRED_SESSION_ENV.into(), + remembered_session_file + .filter(|_| request.explicit_session_file.is_none()) + .map(Into::into), + ), + ]); if let Some(mode) = request.mode() { arguments.push("--mode".into()); @@ -146,7 +180,7 @@ mod tests { false, ); - let launch = build_overlay_launch(&request, default, generation); + let launch = build_overlay_launch(&request, default, generation, None); let mut expected = vec![(NO_DETACH_ENV.into(), Some("1".into()))]; if let Some(generation) = generation { @@ -162,10 +196,56 @@ mod tests { crate::RESUME_SESSION_ENV.into(), expected_resume.map(OsString::from), ), + ( + OVERLAY_SESSION_REPORTS_ENV.into(), + generation.map(|_| "1".into()), + ), + (OVERLAY_HOME_SESSION_ENV.into(), None), + (OVERLAY_PREFERRED_SESSION_ENV.into(), None), ]); assert_eq!(launch.environment, expected); } } } } + + #[test] + fn launch_offers_the_remembered_session_only_without_a_requested_file() { + let home = "/sessions/home.wayscriber-session"; + let remembered = Path::new("/sessions/b.wayscriber-session"); + let requested = "/sessions/c.wayscriber-session"; + for (session_file, preferred) in [(None, Some(remembered)), (Some(requested), None)] { + let request = OverlayLaunchRequest::new( + session_file.map(|file| DaemonToggleRequest { + session_file: Some(file.into()), + ..Default::default() + }), + None, + None, + Some(Path::new(home)), + false, + ); + + let launch = build_overlay_launch(&request, None, Some("generation"), Some(remembered)); + + let value = |name: &str| { + launch + .environment + .iter() + .find(|(key, _)| key == name) + .and_then(|(_, value)| value.clone()) + }; + assert_eq!(value(OVERLAY_SESSION_REPORTS_ENV), Some("1".into())); + assert_eq!(value(OVERLAY_HOME_SESSION_ENV), Some(home.into())); + assert_eq!( + value(OVERLAY_PREFERRED_SESSION_ENV), + preferred.map(Into::into) + ); + // The command line stays one an older overlay understands. + assert_eq!( + launch.arguments, + ["--active", "--session-file", session_file.unwrap_or(home)].map(OsString::from) + ); + } + } } diff --git a/src/daemon/overlay/lifecycle.rs b/src/daemon/overlay/lifecycle.rs index 58368062..c3940942 100644 --- a/src/daemon/overlay/lifecycle.rs +++ b/src/daemon/overlay/lifecycle.rs @@ -7,7 +7,7 @@ use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use log::{debug, info, warn}; -use super::super::protocol_v2::OverlayChildOwner; +use super::super::protocol_v2::{OverlayChildOwner, ReportedSession}; use super::super::types::{BackendRunner, OverlaySpawnCandidate, OverlayState}; use super::launch::{OverlayLaunchRequest, build_overlay_launch}; @@ -66,11 +66,17 @@ impl OverlayLifecycle { self.active_named_session_file.as_deref() } + /// The session the running child last reported, if it reported one. + pub(in crate::daemon) fn reported_session(&self) -> Option { + self.child.reported_session() + } + pub(super) fn start( &mut self, request: &OverlayLaunchRequest, candidate: &OverlaySpawnCandidate, resume_override: &AtomicU8, + remembered_session_file: Option<&Path>, daemon_token: &str, ) -> std::result::Result { self.child @@ -87,6 +93,7 @@ impl OverlayLifecycle { request, crate::decode_session_override(resume_override.load(Ordering::Acquire)), self.child.generation(), + remembered_session_file, ); let attempt = (|| -> Result { diff --git a/src/daemon/overlay/lifecycle/stop.rs b/src/daemon/overlay/lifecycle/stop.rs index d14976fc..6e43bfe0 100644 --- a/src/daemon/overlay/lifecycle/stop.rs +++ b/src/daemon/overlay/lifecycle/stop.rs @@ -6,10 +6,14 @@ use std::thread; use std::time::{Duration, Instant}; use super::OverlayLifecycle; -use crate::daemon::protocol_v2::{BootClock, open_overlay_pidfd, wait_for_pidfd_exit}; +use crate::daemon::protocol_v2::{ + BootClock, ReportedSession, open_overlay_pidfd, wait_for_pidfd_exit, +}; impl OverlayLifecycle { - fn terminate(&mut self) -> Result<()> { + /// Stops the child, returning the session it last reported. + fn terminate(&mut self) -> Result> { + let mut session = None; if let Some(pid) = self.child.display_pid() { let stop_started = Instant::now(); let timeout = Duration::from_secs(2); @@ -31,12 +35,13 @@ impl OverlayLifecycle { let deadline = BootClock::now()?.checked_add(timeout)?; loop { match self.child.try_wait() { - Ok(Some(status)) => { + Ok(Some(exit)) => { info!( "Overlay process exited with status {:?} after {:?}", - status, + exit.status, stop_started.elapsed() ); + session = exit.session; break; } Ok(None) => { @@ -45,15 +50,16 @@ impl OverlayLifecycle { "Overlay process did not exit after {:?}, sending SIGKILL", stop_started.elapsed() ); - let status = self + let exit = self .child .force_kill_and_wait() .context("lost broker ownership while forcing overlay shutdown")?; warn!( "Overlay process killed with status {:?} after {:?}", - status, + exit.status, stop_started.elapsed() ); + session = exit.session; break; } // Without a pidfd (the child raced us to exit, or the @@ -86,24 +92,27 @@ impl OverlayLifecycle { self.active.store(false, Ordering::Release); self.active_named_session_file = None; - Ok(()) + Ok(session) } - pub(in crate::daemon::overlay) fn hide(&mut self) -> Result<()> { - self.terminate()?; + /// Stops the overlay, returning the session its child last reported. + pub(in crate::daemon::overlay) fn hide(&mut self) -> Result> { + let session = self.terminate()?; self.mark_hidden(); - Ok(()) + Ok(session) } - pub(in crate::daemon::overlay) fn poll_exit(&mut self) -> Result<()> { + /// Retires a child that exited on its own, returning the session it last + /// reported. + pub(in crate::daemon::overlay) fn poll_exit(&mut self) -> Result> { match self.child.try_wait() { - Ok(Some(status)) => { - info!("Overlay process exited with status {:?}", status); + Ok(Some(exit)) => { + info!("Overlay process exited with status {:?}", exit.status); self.mark_hidden(); + Ok(exit.session) } - Ok(None) => {} - Err(err) => return Err(err).context("lost broker ownership of overlay child"), + Ok(None) => Ok(None), + Err(err) => Err(err).context("lost broker ownership of overlay child"), } - Ok(()) } } diff --git a/src/daemon/overlay/mod.rs b/src/daemon/overlay/mod.rs index e6de2b96..34ada582 100644 --- a/src/daemon/overlay/mod.rs +++ b/src/daemon/overlay/mod.rs @@ -6,6 +6,8 @@ use log::{debug, info}; use super::core::Daemon; use super::types::{OverlaySpawnCandidate, OverlayState}; use crate::daemon::control::DaemonToggleRequest; +use crate::daemon::protocol_v2::ReportedSession; +use crate::session::catalog::session_paths_match; pub(super) mod launch; pub(super) mod lifecycle; @@ -101,6 +103,7 @@ impl Daemon { match self.spawn_overlay_process(&request, candidates) { Ok(()) => { self.clear_overlay_spawn_error(); + self.forget_remembered_session_if_started_at_home(&request); Ok(ShowOutcome::Shown) } Err(failure) => { @@ -187,8 +190,32 @@ impl Daemon { return Ok(()); } - self.overlay.hide()?; + let session = self.overlay.hide()?; + self.remember_reported_session(session); self.discard_pending_launch_options(); Ok(()) } + + /// Remembers the session a retired overlay last reported, for the next + /// show to continue. Without a report the daemon keeps what it knew. + pub(super) fn remember_reported_session(&mut self, session: Option) { + match session { + None => {} + Some(ReportedSession::Home) => self.remembered_session_file = None, + Some(ReportedSession::Named(path)) => self.remembered_session_file = Some(path), + } + } + + /// A request for home started an overlay at home. The overlay has no + /// session change to report, so the daemon forgets its remembered session + /// itself rather than return to it on the next show. + fn forget_remembered_session_if_started_at_home(&mut self, request: &OverlayLaunchRequest) { + if let (Some(requested), Some(home)) = ( + request.explicit_session_file(), + self.initial_named_session_file.as_deref(), + ) && session_paths_match(requested, home) + { + self.remembered_session_file = None; + } + } } diff --git a/src/daemon/overlay/process.rs b/src/daemon/overlay/process.rs index 4e7df083..0e624336 100644 --- a/src/daemon/overlay/process.rs +++ b/src/daemon/overlay/process.rs @@ -8,6 +8,8 @@ impl Daemon { return Ok(()); } - self.overlay.poll_exit() + let session = self.overlay.poll_exit()?; + self.remember_reported_session(session); + Ok(()) } } diff --git a/src/daemon/overlay/spawn.rs b/src/daemon/overlay/spawn.rs index 3278e985..0ca6e34c 100644 --- a/src/daemon/overlay/spawn.rs +++ b/src/daemon/overlay/spawn.rs @@ -108,6 +108,7 @@ impl Daemon { request, candidate, &self.session_resume_override, + self.remembered_session_file.as_deref(), &self.instance_token, ) { Ok(pid) => { @@ -175,7 +176,7 @@ mod tests { fn build_test_launch(daemon: &mut Daemon) -> OverlayLaunch { let request = daemon.take_pending_launch(); - build_overlay_launch(&request, daemon.session_resume_override(), None) + build_overlay_launch(&request, daemon.session_resume_override(), None, None) } fn launch_args(launch: &OverlayLaunch) -> Vec { diff --git a/src/daemon/overlay/tests.rs b/src/daemon/overlay/tests.rs index 20d55bc9..358dd9c9 100644 --- a/src/daemon/overlay/tests.rs +++ b/src/daemon/overlay/tests.rs @@ -33,6 +33,19 @@ pub(in crate::daemon) fn with_visible_overlay( }); } +/// Like [`with_visible_overlay`], for an overlay that reports `session`, a +/// path or `"home"`, once shown. +pub(in crate::daemon) fn with_reporting_overlay(session: &str, body: impl FnOnce(&mut Daemon)) { + with_fixture(false, |daemon, _, root| { + instruct(root, Some(session), false); + show(daemon, root); + + body(daemon); + + daemon.hide_overlay().unwrap(); + }); +} + pub(in crate::daemon) fn assert_token_only_pending_launch(daemon: &Daemon, token: &str) { let retained = daemon.pending_launch.as_ref().expect("token must be kept"); @@ -109,6 +122,150 @@ fn assert_retired(daemon: &Daemon, generation: &str) { for suffix in ["active", "enabled", "ready", "signals"] { assert!(!proofs.join(format!("{generation}.{suffix}")).exists()); } + assert!(!session_report(generation).exists()); +} + +fn session_report(generation: &str) -> PathBuf { + crate::paths::daemon_command_dir() + .join("overlay-targets") + .join(format!("{generation}.target")) +} + +/// Has the next fake overlay report `report`, `"home"` or a path, and exit +/// afterwards when `exit` is set. +fn instruct(root: &Path, report: Option<&str>, exit: bool) { + fs::write( + root.join(fake_overlay::SESSION_INSTRUCTION), + serde_json::json!({ "report": report, "exit": exit }).to_string(), + ) + .unwrap(); +} + +/// Shows the overlay and returns its launch receipt, removed so the next +/// show's can be told apart. +fn show(daemon: &mut Daemon, root: &Path) -> serde_json::Value { + daemon.show_overlay().unwrap().require_shown().unwrap(); + let receipt = receipt(root); + fs::remove_file(root.join(format!( + "{}.receipt", + receipt["generation"].as_str().unwrap() + ))) + .unwrap(); + receipt +} + +fn wait_until_retired(daemon: &mut Daemon) { + let deadline = Instant::now() + Duration::from_secs(3); + while daemon.overlay.state() == OverlayState::Visible { + daemon.update_overlay_process_state().unwrap(); + assert!(Instant::now() < deadline, "exited child was not retired"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +const HOME: &str = "/tmp/home.wayscriber-session"; +const REMEMBERED: &str = "/tmp/remembered.wayscriber-session"; + +#[test] +fn the_next_show_continues_the_session_the_overlay_reported() { + with_fixture(false, |daemon, _, root| { + instruct(root, Some(REMEMBERED), false); + let first = show(daemon, root); + assert_eq!(first["reports"], "1"); + assert_eq!(first["home"], HOME); + assert!(first["preferred"].is_null()); + let generation = first["generation"].as_str().unwrap(); + assert!(session_report(generation).exists()); + + daemon.hide_overlay().unwrap(); + assert_retired(daemon, generation); + assert_eq!( + daemon.remembered_session_file.as_deref(), + Some(Path::new(REMEMBERED)) + ); + + // An older overlay still starts at home; a current one continues. + instruct(root, Some("home"), false); + let second = show(daemon, root); + assert_eq!( + second["args"], + serde_json::json!(["--active", "--mode", "transparent", "--session-file", HOME]) + ); + assert_eq!(second["home"], HOME); + assert_eq!(second["preferred"], REMEMBERED); + + // Back home, the overlay exits on its own and the daemon forgets. + daemon.overlay.signal(libc::SIGTERM).unwrap(); + wait_until_retired(daemon); + assert_retired(daemon, second["generation"].as_str().unwrap()); + assert_eq!(daemon.remembered_session_file, None); + + instruct(root, None, false); + let third = show(daemon, root); + assert!(third["preferred"].is_null()); + daemon.hide_overlay().unwrap(); + }); +} + +#[test] +fn an_overlay_that_reports_and_exits_at_once_is_still_heard() { + with_fixture(false, |daemon, _, root| { + instruct(root, Some(REMEMBERED), true); + let receipt = show(daemon, root); + + wait_until_retired(daemon); + + assert_retired(daemon, receipt["generation"].as_str().unwrap()); + assert_eq!( + daemon.remembered_session_file.as_deref(), + Some(Path::new(REMEMBERED)) + ); + }); +} + +#[test] +fn a_forced_stop_still_reads_the_last_report() { + with_fixture(true, |daemon, _, root| { + instruct(root, Some(REMEMBERED), false); + let receipt = show(daemon, root); + + daemon.hide_overlay().unwrap(); + + assert_retired(daemon, receipt["generation"].as_str().unwrap()); + assert_eq!( + daemon.remembered_session_file.as_deref(), + Some(Path::new(REMEMBERED)) + ); + }); +} + +#[test] +fn a_requested_session_file_outranks_the_remembered_one_for_that_show() { + with_fixture(false, |daemon, _, root| { + daemon.remembered_session_file = Some(PathBuf::from(REMEMBERED)); + let requested = "/tmp/requested.wayscriber-session"; + for (session_file, remembered_after) in [(requested, Some(REMEMBERED)), (HOME, None)] { + daemon.queue_overlay_launch( + Some(DaemonToggleRequest { + session_file: Some(PathBuf::from(session_file)), + ..Default::default() + }), + None, + ); + + let receipt = show(daemon, root); + + assert_eq!(receipt["args"][4], session_file); + assert!(receipt["preferred"].is_null(), "{session_file}"); + // A request for home returns home: nothing remains to continue. + assert_eq!( + daemon.remembered_session_file.as_deref(), + remembered_after.map(Path::new), + "{session_file}" + ); + daemon.hide_overlay().unwrap(); + } + }); } #[test] @@ -311,7 +468,7 @@ fn preparation_error_keeps_only_the_token_and_does_not_replay_options() { assert!(daemon.start_launch(request, &[candidate]).is_err()); let retained = daemon.take_pending_launch(); - let actual = launch::build_overlay_launch(&retained, Some(true), None); + let actual = launch::build_overlay_launch(&retained, Some(true), None, None); assert_eq!( actual.arguments, diff --git a/src/daemon/overlay/tests/fake_overlay.rs b/src/daemon/overlay/tests/fake_overlay.rs index 946c856d..4417f20d 100644 --- a/src/daemon/overlay/tests/fake_overlay.rs +++ b/src/daemon/overlay/tests/fake_overlay.rs @@ -3,10 +3,11 @@ //! Spawn candidate discovery tries `current_exe()` first, which under //! `cargo test` is this test binary. A fixture marks the environment of the //! broker it starts; when the daemon then launches this binary as an overlay -//! child, the constructor below runs the production child handshake and -//! records how it was launched, before libtest would parse the overlay -//! arguments. Without both the fixture marker and an overlay generation, the -//! constructor returns and the binary runs its tests as usual. +//! child, the constructor below runs the production child handshake, reports a +//! session when the test asks for one, and records how it was launched, before +//! libtest would parse the overlay arguments. Without both the fixture marker +//! and an overlay generation, the constructor returns and the binary runs its +//! tests as usual. use std::convert::Infallible; use std::ffi::OsStr; @@ -17,7 +18,8 @@ use std::time::Duration; use anyhow::{Context, Result}; use crate::daemon::protocol_v2::{ - ActiveGeneration, active_generation_from_environment, publish_ready_from_environment, + ActiveGeneration, ReportedSession, active_generation_from_environment, + publish_ready_from_environment, publish_session_from_environment, publish_signal_ready_from_environment, }; @@ -35,6 +37,10 @@ pub(super) const EXITS_BEFORE_READY: &str = "wayscriber-exits-before-ready"; /// Written to the fixture's runtime directory as that child starts, so a test /// can tell a child that ran and exited from one that never started. pub(super) const STARTED_THEN_EXITED: &str = "started-then-exited"; +/// A test writes this JSON object to the fixture's runtime directory before a +/// show to direct the next fake overlay: `report` is the session it reports +/// once enabled, `"home"` or a path, and `exit` makes it exit after that. +pub(super) const SESSION_INSTRUCTION: &str = "fake-overlay-session.json"; const EXIT_BEFORE_READY_STATUS: i32 = 7; /// The fake overlay could not serve; the test that launched it then fails. const FAILURE_STATUS: i32 = 1; @@ -89,13 +95,40 @@ fn serve(ignore_term: bool) -> Result { std::thread::sleep(Duration::from_millis(2)); } + // Reported before the receipt, so a test that has the receipt can rely on + // the report. + let exit = report_session()?; write_receipt()?; + if exit { + std::process::exit(0); + } loop { std::thread::sleep(Duration::from_secs(60)); } } +/// Follows the test's [`SESSION_INSTRUCTION`], returning whether to exit. +fn report_session() -> Result { + let instruction = match std::fs::read(runtime_root()?.join(SESSION_INSTRUCTION)) { + Ok(bytes) => serde_json::from_slice::(&bytes)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error).context("failed to read the session instruction"), + }; + if let Some(report) = instruction["report"].as_str() { + let session = match report { + "home" => ReportedSession::Home, + path => ReportedSession::Named(path.into()), + }; + anyhow::ensure!( + publish_session_from_environment(&session)?, + "the daemon did not ask for session reports" + ); + } + + Ok(instruction["exit"].as_bool().unwrap_or(false)) +} + fn write_receipt() -> Result<()> { // As launched: GTK may already have unset the startup-notification variables. let launched_with = |name: &str| { @@ -107,6 +140,9 @@ fn write_receipt() -> Result<()> { "startup": launched_with(crate::env_vars::DESKTOP_STARTUP_ID_ENV), "resume": launched_with(crate::RESUME_SESSION_ENV), "detach": launched_with(crate::env_vars::NO_DETACH_ENV), + "reports": launched_with(crate::env_vars::OVERLAY_SESSION_REPORTS_ENV), + "home": launched_with(crate::env_vars::OVERLAY_HOME_SESSION_ENV), + "preferred": launched_with(crate::env_vars::OVERLAY_PREFERRED_SESSION_ENV), "pid": std::process::id(), "generation": std::env::var(crate::env_vars::OVERLAY_CHILD_GENERATION_ENV)?, }); @@ -117,11 +153,9 @@ fn write_receipt() -> Result<()> { /// Writes `.` into the fixture's runtime directory. fn record(kind: &str, contents: &[u8]) -> Result<()> { let generation = std::env::var(crate::env_vars::OVERLAY_CHILD_GENERATION_ENV)?; - let root = std::env::var_os(crate::env_vars::XDG_RUNTIME_DIR_ENV) - .context("fake overlay needs the fixture's runtime directory")?; crate::durable_io::write_atomic( - &Path::new(&root).join(format!("{generation}.{kind}")), + &runtime_root()?.join(format!("{generation}.{kind}")), contents, crate::durable_io::AtomicWriteOptions::private_runtime_file(), )?; @@ -129,6 +163,12 @@ fn record(kind: &str, contents: &[u8]) -> Result<()> { Ok(()) } +fn runtime_root() -> Result { + std::env::var_os(crate::env_vars::XDG_RUNTIME_DIR_ENV) + .map(Into::into) + .context("fake overlay needs the fixture's runtime directory") +} + /// Whether this process was started as `name`: the broker passes the program /// path it was given as `argv[0]`, which for a link is the link's own path. fn launched_as(name: &str) -> bool { diff --git a/src/daemon/protocol_v2/child.rs b/src/daemon/protocol_v2/child.rs index efc8ac6d..b36e11f5 100644 --- a/src/daemon/protocol_v2/child.rs +++ b/src/daemon/protocol_v2/child.rs @@ -7,6 +7,9 @@ use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; use serde::{Deserialize, Serialize}; +use super::session_target::{ + ReportedSession, discard_session_report, read_session_report, take_final_session_report, +}; use super::wire::fresh_id; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -25,6 +28,17 @@ struct OwnedOverlayChild { display_pid: u32, pidfd: OwnedFd, child: crate::process_broker::BrokerChild, + /// Captured when its readiness proved its identity, so its reports can be + /// checked after it exits. + process_start_ticks: Option, +} + +/// How an owned overlay child ended. +#[derive(Debug)] +pub(crate) struct OverlayExit { + pub(crate) status: i32, + /// The session it last reported, read before its identity was released. + pub(crate) session: Option, } #[derive(Debug)] @@ -127,6 +141,7 @@ impl OverlayChildOwner { display_pid, pidfd, child, + process_start_ticks: None, }); self.phase = OverlayChildPhase::Starting; Ok(()) @@ -172,6 +187,9 @@ impl OverlayChildOwner { { bail!("overlay readiness identity mismatch"); } + if let Some(owned) = self.owned.as_mut() { + owned.process_start_ticks = Some(record.process_start_ticks); + } let signals_bytes = match super::linux::read_bounded_regular_file( &signals_path(&generation), 1024, @@ -243,6 +261,7 @@ impl OverlayChildOwner { pub(crate) fn abort_reservation(&mut self) { if let Some(generation) = self.generation().map(str::to_owned) { clear_generation_records(&generation); + discard_session_report(&generation); } if self.display_pid().is_some_and(|pid| pid != 0) { let _ = self.force_kill_and_wait(); @@ -265,26 +284,29 @@ impl OverlayChildOwner { owned.child.signal(signal) } - pub(crate) fn try_wait(&mut self) -> Result> { + /// The session the running child last reported. A report that cannot be + /// trusted counts as none. + pub(crate) fn reported_session(&self) -> Option { + let owned = self.owned.as_ref()?; + let generation = self.generation()?; + let process_start_ticks = owned.process_start_ticks?; + read_session_report(generation, owned.display_pid, process_start_ticks).unwrap_or_else( + |error| { + log::warn!("Ignoring the session report of overlay child {generation}: {error:#}"); + None + }, + ) + } + + pub(crate) fn try_wait(&mut self) -> Result> { let Some(owned) = self.owned.as_mut() else { return Ok(None); }; - match owned + let status = owned .child .try_wait() - .context("failed to query overlay child")? - { - Some(status) => { - if let Some(generation) = self.generation().map(str::to_owned) { - clear_generation_records(&generation); - } - self.owned = None; - self.generation = None; - self.phase = OverlayChildPhase::Stopped; - Ok(Some(status)) - } - None => Ok(None), - } + .context("failed to query overlay child")?; + Ok(status.map(|status| self.retire(status))) } pub(crate) fn begin_stop(&mut self) -> Result<()> { @@ -297,7 +319,7 @@ impl OverlayChildOwner { self.signal(libc::SIGTERM) } - pub(crate) fn force_kill_and_wait(&mut self) -> Result { + pub(crate) fn force_kill_and_wait(&mut self) -> Result { let owned = self .owned .as_mut() @@ -306,13 +328,29 @@ impl OverlayChildOwner { .child .kill_wait() .context("broker failed to kill and reap overlay child")?; - if let Some(generation) = self.generation().map(str::to_owned) { + Ok(self.retire(status)) + } + + /// Ends ownership of the reaped child: reads its final session report while + /// its identity is still held, then clears its proofs and releases it. + fn retire(&mut self, status: i32) -> OverlayExit { + let session = match (self.generation(), self.owned.as_ref()) { + (Some(generation), Some(owned)) => match owned.process_start_ticks { + Some(ticks) => take_final_session_report(generation, owned.display_pid, ticks), + None => { + discard_session_report(generation); + None + } + }, + _ => None, + }; + if let Some(generation) = self.generation.take() { clear_generation_records(&generation); } self.owned = None; - self.generation = None; self.phase = OverlayChildPhase::Stopped; - Ok(status) + + OverlayExit { status, session } } } diff --git a/src/daemon/protocol_v2/mod.rs b/src/daemon/protocol_v2/mod.rs index 19b8d34b..ee2d323d 100644 --- a/src/daemon/protocol_v2/mod.rs +++ b/src/daemon/protocol_v2/mod.rs @@ -25,7 +25,9 @@ pub(crate) use linux::{ }; pub(crate) use mode::DaemonControlProtocolMode; pub(crate) use runtime::{ClassifiedRuntimeRecord, read_runtime_record, write_runtime_record_v2}; -pub(crate) use session_target::{ReportedSession, publish_session_from_environment}; +pub(crate) use session_target::{ + ReportedSession, clear_stale_session_reports, publish_session_from_environment, +}; pub(crate) use wire::EffectKind; pub(crate) use wire::{DaemonRequestV2, DaemonRuntimeRecordV2}; diff --git a/src/daemon/protocol_v2/session_target.rs b/src/daemon/protocol_v2/session_target.rs index 11c0835c..08e07124 100644 --- a/src/daemon/protocol_v2/session_target.rs +++ b/src/daemon/protocol_v2/session_target.rs @@ -107,7 +107,7 @@ fn report_path_text(path: &Path) -> Result { } fn create_report_dir() -> Result<()> { - use std::os::unix::fs::{MetadataExt, PermissionsExt}; + use std::os::unix::fs::PermissionsExt; let directory = report_dir(); match std::fs::create_dir(&directory) { @@ -118,14 +118,169 @@ fn create_report_dir() -> Result<()> { } } let metadata = std::fs::symlink_metadata(&directory)?; - // SAFETY: geteuid has no preconditions and cannot fail. - let owner = unsafe { libc::geteuid() }; - if !metadata.is_dir() || metadata.uid() != owner { + if !metadata.is_dir() || !owned_by_this_user(&metadata) { bail!("{} is not a private report directory", directory.display()); } std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700))?; Ok(()) } +/// The session the child `generation` last reported, if it reported one under +/// exactly this identity. The identity is the one the daemon owns, captured +/// while the child ran: an exited child no longer has a `/proc` entry to ask. +pub(crate) fn read_session_report( + generation: &str, + pid: u32, + process_start_ticks: u64, +) -> Result> { + super::wire::validate_id(generation)?; + match std::fs::symlink_metadata(report_dir()) { + Ok(metadata) if metadata.is_dir() && is_private(&metadata) => {} + Ok(_) => bail!("the session report directory is not private"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).context("failed to inspect the session reports"), + } + let bytes = match read_private_file(&report_path(generation)) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).context("failed to read the session report"), + }; + let record: SessionTargetRecord = super::wire::parse_canonical_json(&bytes, MAX_REPORT_BYTES)?; + if record.schema != REPORT_SCHEMA + || record.generation != generation + || record.pid != pid + || record.process_start_ticks != process_start_ticks + { + bail!("the session report belongs to another overlay child"); + } + + match record.target.map(PathBuf::from) { + None => Ok(Some(ReportedSession::Home)), + Some(path) if path.is_absolute() => Ok(Some(ReportedSession::Named(path))), + Some(path) => bail!("reported session {} is not absolute", path.display()), + } +} + +/// Reads the final report of the exited child `generation`, then removes it +/// along with any temporary its writer left. A report that cannot be trusted +/// is logged and ignored, so it never stands in the way of retiring the child. +pub(crate) fn take_final_session_report( + generation: &str, + pid: u32, + process_start_ticks: u64, +) -> Option { + let session = + read_session_report(generation, pid, process_start_ticks).unwrap_or_else(|error| { + log::warn!("Ignoring the session report of overlay child {generation}: {error:#}"); + None + }); + discard_session_report(generation); + session +} + +/// Removes the report of child `generation` and any temporary its writer left. +pub(crate) fn discard_session_report(generation: &str) { + let report = format!("{generation}.target"); + // The report itself goes by name, however full the directory is. + let removed = match std::fs::remove_file(report_dir().join(&report)) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => { + Err(anyhow::Error::new(error).context("failed to remove the report")) + } + _ => remove_reports(|target| target == report), + }; + if let Err(error) = removed { + log::warn!("Failed to remove the session report of overlay child {generation}: {error:#}"); + } +} + +/// Removes every report an earlier daemon left behind, restoring nothing from +/// them: the session a daemon remembered ends with that daemon. +pub(crate) fn clear_stale_session_reports() -> Result<()> { + remove_reports(|target| { + target + .strip_suffix(".target") + .is_some_and(|generation| super::wire::validate_id(generation).is_ok()) + }) +} + +/// Bounds how many entries one cleanup looks at, so a flooded directory costs +/// a fixed amount of work. +const MAX_CLEANUP_ENTRIES: usize = 256; + +/// Removes each report, and each temporary left by a report's writer, whose +/// report name satisfies `is_removed`. Anything else in the directory is not +/// this daemon's to remove. +fn remove_reports(is_removed: impl Fn(&str) -> bool) -> Result<()> { + let directory = report_dir(); + let entries = match std::fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(error).with_context(|| format!("failed to list {}", directory.display())); + } + }; + for entry in entries.take(MAX_CLEANUP_ENTRIES) { + let entry = entry?; + let Ok(name) = entry.file_name().into_string() else { + continue; + }; + let report = crate::durable_io::temp_file_target(&name).unwrap_or(&name); + if !is_removed(report) { + continue; + } + match std::fs::remove_file(entry.path()) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("failed to remove {}", entry.path().display())); + } + } + } + Ok(()) +} + +/// Reads a regular file this user owns and only this user can read or write, +/// without following a symlink. +fn read_private_file(path: &Path) -> std::io::Result> { + use std::io::Read; + use std::os::unix::fs::OpenOptionsExt; + + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() || !is_private(&metadata) || metadata.len() > MAX_REPORT_BYTES as u64 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "session report is not a private regular file within its size bound", + )); + } + let mut bytes = Vec::new(); + file.take(MAX_REPORT_BYTES as u64 + 1) + .read_to_end(&mut bytes)?; + if bytes.len() > MAX_REPORT_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "session report exceeds its size bound", + )); + } + Ok(bytes) +} + +fn is_private(metadata: &std::fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + + owned_by_this_user(metadata) && metadata.mode() & 0o077 == 0 +} + +fn owned_by_this_user(metadata: &std::fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + + // SAFETY: geteuid has no preconditions and cannot fail. + metadata.uid() == unsafe { libc::geteuid() } +} + #[cfg(test)] mod tests; diff --git a/src/daemon/protocol_v2/session_target/tests.rs b/src/daemon/protocol_v2/session_target/tests.rs index e7bf2698..5275201d 100644 --- a/src/daemon/protocol_v2/session_target/tests.rs +++ b/src/daemon/protocol_v2/session_target/tests.rs @@ -72,3 +72,146 @@ fn an_overlay_without_its_child_identity_cannot_report() { assert!(!report_path(generation).exists()); }); } + +fn current_identity() -> (u32, u64) { + ( + std::process::id(), + super::super::linux::current_process_start_ticks().unwrap(), + ) +} + +/// Writes `bytes` as `name` in the report directory, private like the writer +/// leaves it unless `mode` says otherwise. +fn write_entry(name: &str, bytes: &[u8], mode: u32) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + // An overlay's identity proof creates this before any report is written. + std::fs::create_dir_all(crate::paths::daemon_command_dir()).unwrap(); + create_report_dir().unwrap(); + let path = report_dir().join(name); + std::fs::write(&path, bytes).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); + path +} + +fn record_bytes(generation: &str, pid: u32, ticks: u64, target: Option<&str>) -> Vec { + super::super::wire::canonical_json( + &SessionTargetRecord { + schema: REPORT_SCHEMA, + generation: generation.to_owned(), + pid, + process_start_ticks: ticks, + target: target.map(str::to_owned), + }, + MAX_REPORT_BYTES, + ) + .unwrap() +} + +#[test] +fn the_daemon_reads_a_report_only_under_the_identity_it_owns() { + as_daemon_overlay(Some("1"), |generation| { + super::super::publish_ready_from_environment().unwrap(); + let session = ReportedSession::Named(PathBuf::from("/sessions/b.wayscriber-session")); + publish_session_from_environment(&session).unwrap(); + let (pid, ticks) = current_identity(); + + assert_eq!( + read_session_report(generation, pid, ticks).unwrap(), + Some(session) + ); + assert!(read_session_report(generation, pid + 1, ticks).is_err()); + assert!(read_session_report(generation, pid, ticks + 1).is_err()); + let other = super::super::ProtocolId::generate().unwrap().to_string(); + assert_eq!(read_session_report(&other, pid, ticks).unwrap(), None); + }); +} + +#[test] +fn an_untrusted_report_is_ignored_and_still_removed() { + as_daemon_overlay(Some("1"), |generation| { + let (pid, ticks) = current_identity(); + let name = format!("{generation}.target"); + let other = super::super::ProtocolId::generate().unwrap().to_string(); + let foreign = record_bytes(&other, pid, ticks, None); + let relative = record_bytes(generation, pid, ticks, Some("b.wayscriber-session")); + let mut newer_schema = + String::from_utf8(record_bytes(generation, pid, ticks, None)).unwrap(); + newer_schema = newer_schema.replace(&format!("\"schema\":{REPORT_SCHEMA}"), "\"schema\":2"); + let oversize = vec![b' '; MAX_REPORT_BYTES + 1]; + let valid = record_bytes(generation, pid, ticks, None); + + for (case, bytes, mode) in [ + ("malformed", b"{".as_slice(), 0o600), + ("another generation's record", &foreign, 0o600), + ("relative target", &relative, 0o600), + ("newer schema", newer_schema.as_bytes(), 0o600), + ("oversize", &oversize, 0o600), + ("readable by others", &valid, 0o644), + ] { + let path = write_entry(&name, bytes, mode); + + assert_eq!( + take_final_session_report(generation, pid, ticks), + None, + "{case}" + ); + assert!(!path.exists(), "{case}"); + } + + let target = write_entry("elsewhere", &valid, 0o600); + std::os::unix::fs::symlink(&target, report_path(generation)).unwrap(); + assert_eq!(take_final_session_report(generation, pid, ticks), None); + assert!(std::fs::symlink_metadata(report_path(generation)).is_err()); + assert!(target.exists(), "only the report's own name is removed"); + }); +} + +#[test] +fn retirement_removes_a_childs_report_and_its_writer_temporaries() { + as_daemon_overlay(Some("1"), |generation| { + let (pid, ticks) = current_identity(); + let report = write_entry( + &format!("{generation}.target"), + &record_bytes( + generation, + pid, + ticks, + Some("/sessions/b.wayscriber-session"), + ), + 0o600, + ); + let temporary = write_entry(&format!(".{generation}.target.1.2.3.tmp"), b"{", 0o600); + let other = super::super::ProtocolId::generate().unwrap().to_string(); + let other_report = write_entry(&format!("{other}.target"), b"{", 0o600); + + assert_eq!( + take_final_session_report(generation, pid, ticks), + Some(ReportedSession::Named(PathBuf::from( + "/sessions/b.wayscriber-session" + ))) + ); + assert!(!report.exists()); + assert!(!temporary.exists()); + assert!(other_report.exists()); + }); +} + +#[test] +fn startup_removes_every_stale_report_and_nothing_else() { + as_daemon_overlay(None, |generation| { + let stale = [ + write_entry(&format!("{generation}.target"), b"{", 0o600), + write_entry(&format!(".{generation}.target.1.2.3.tmp"), b"{", 0o600), + ]; + let unrelated = [ + write_entry("notes.txt", b"kept", 0o600), + write_entry("not-an-id.target", b"kept", 0o600), + ]; + + clear_stale_session_reports().unwrap(); + + assert!(stale.iter().all(|path| !path.exists())); + assert!(unrelated.iter().all(|path| path.exists())); + }); +} diff --git a/src/process_broker/manifest.rs b/src/process_broker/manifest.rs index da027a4d..4467a48b 100644 --- a/src/process_broker/manifest.rs +++ b/src/process_broker/manifest.rs @@ -98,6 +98,9 @@ pub(super) fn validate( | "DESKTOP_STARTUP_ID" | "WAYSCRIBER_RESUME_SESSION" | "WAYSCRIBER_OVERLAY_CHILD_GENERATION" + | "WAYSCRIBER_OVERLAY_SESSION_REPORTS" + | "WAYSCRIBER_OVERLAY_HOME_SESSION" + | "WAYSCRIBER_OVERLAY_PREFERRED_SESSION" ) { bail!("environment key {name:?} is not broker-allowed"); } @@ -276,11 +279,14 @@ fn looks_like_uri_bytes(value: &[u8]) -> bool { /// spawned, and from there xdg-open's browser, the configurator, tesseract /// and curl. A wayscriber started anywhere in those trees then took itself /// for a daemon child. -const INTERNAL_PROCESS_MARKERS: [&str; 4] = [ +const INTERNAL_PROCESS_MARKERS: [&str; 7] = [ crate::env_vars::OVERLAY_CHILD_GENERATION_ENV, crate::env_vars::DETACHED_ENV, crate::RESUME_SESSION_ENV, crate::env_vars::DAEMON_WATCHDOG_FD_ENV, + crate::env_vars::OVERLAY_SESSION_REPORTS_ENV, + crate::env_vars::OVERLAY_HOME_SESSION_ENV, + crate::env_vars::OVERLAY_PREFERRED_SESSION_ENV, ]; /// Whether `kind` relaunches wayscriber itself, the only helpers that keep From d5baaaddce95178f1e2c7d358eee97a26ffb0c45 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:21:53 +0200 Subject: [PATCH 06/14] feat(session): return to the home session from the Session menu The Session popover gains a way back to the home session: "Back to " when the overlay or its daemon started with a session file, or "Default session" without one. It is disabled while home is already active, and the side.session.home toolbar item hides it. The label and destination come from the launch's home input, not from the session the overlay is in. Returning home is an explicit session command. Like Open, it saves dirty current data first, refuses if the canvas or interaction changes while it is pending, and replaces nothing unless home loads. Home loads the way a launch would, from its own options for the current output, so recovery, clear-boundary and tool-restore rules apply, and its load errors stay those of a startup session file rather than becoming an empty session. A home with persistence disabled continues unsaved on an empty canvas. Once home is committed the overlay reports it, so the daemon forgets the session it remembered. The overlay keeps whether it is home with its reported target, so the menu needs no file system check on redraw. --- README.md | 2 +- docs/CONFIG.md | 3 +- docs/codebase-overview.md | 14 +-- examples/session-manager.md | 3 + src/backend/wayland/session.rs | 16 ++- src/backend/wayland/session/driver/tests.rs | 98 +++++++++++++++++++ src/backend/wayland/session/home.rs | 48 ++++++--- src/backend/wayland/session/home/tests.rs | 43 ++++++-- src/backend/wayland/session/runtime.rs | 10 ++ .../wayland/session/runtime/transaction.rs | 22 +++++ .../session/runtime/transaction/phases.rs | 28 ++++++ .../wayland/state/core/output/session_ops.rs | 2 +- .../wayland/state/core/session_home.rs | 2 +- src/backend/wayland/state/toolbar/events.rs | 2 +- .../wayland/state/toolbar/events/session.rs | 38 ++++++- .../state/toolbar/events/session/home.rs | 76 ++++++++++++++ .../wayland/state/toolbar/events/tests.rs | 2 + src/backend/wayland/toolbar/view/top/menus.rs | 17 ++++ src/backend/wayland/toolbar/view/top/tests.rs | 25 +++++ src/config/types/toolbar/ids.rs | 1 + src/config/types/toolbar/items/definitions.rs | 7 ++ src/input/state/core/toolbar/apply/mod.rs | 1 + src/toolbar_gtk/view/sections/session_pane.rs | 22 ++++- src/toolbar_gtk/view/top_bar.rs | 4 + .../view/top_bar/tests/pane_popovers.rs | 20 +++- src/ui/toolbar/events.rs | 3 + src/ui/toolbar/model/event_policy.rs | 3 + src/ui/toolbar/model/mod.rs | 14 +++ src/ui/toolbar/model/session.rs | 58 ++++++++++- src/ui/toolbar/snapshot/build.rs | 2 + src/ui/toolbar/snapshot/types.rs | 5 + 31 files changed, 552 insertions(+), 39 deletions(-) create mode 100644 src/backend/wayland/state/toolbar/events/session/home.rs diff --git a/README.md b/README.md index 17f1002e..4b998b46 100644 --- a/README.md +++ b/README.md @@ -1212,7 +1212,7 @@ See [Session manager examples](examples/session-manager.md) for complete CLI, ov - When a fresh launch restores ink onto the transparent overlay board, a toast such as "Restored 7 annotations from last session" offers **Clear** (undoable, like Clear Canvas), because that ink now sits over whatever is on screen. Daemon toggles, empty restores, and solid boards stay quiet. - Config values seed startup defaults. When `restore_tool_state` is enabled (default), the last-used tool settings saved in the session (including arrow head placement and the starting Spotlight magnification) override those config defaults on startup. Run `wayscriber --clear-tool-state` to remove only that saved tool layer so config defaults apply next startup while saved boards/history remain. In a running overlay, use Command Palette → Reset Tool Defaults to clear the saved layer and immediately apply config defaults to the active tools. - `--session-file` uses exactly the selected file, implies persistence for that overlay run, rejects directories/symlinks/special files, and does not create missing parent directories. A running daemon can launch a hidden overlay with a named target; if the overlay is already visible, hide it before switching to a different named session. The daemon's overlay keeps the session it last opened or saved as across hide and show, while the daemon runs. -- The overlay Session controls live in the top toolbar's Session popover (overflow menu → Session...). They can open an existing named session, save the current overlay as another named session, show session info, clear the active session, reopen recent named sessions, and jump to the configurator. The Open/Save As dialogs use `zenity` or `kdialog`; Save As appends `.wayscriber-session` when no extension is supplied and asks before replacing existing session artifacts. +- The overlay Session controls live in the top toolbar's Session popover (overflow menu → Session...). They can open an existing named session, save the current overlay as another named session, return to the home session (**Back to** the startup session file, or **Default session**), show session info, clear the active session, reopen recent named sessions, and jump to the configurator. The Open/Save As dialogs use `zenity` or `kdialog`; Save As appends `.wayscriber-session` when no extension is supplied and asks before replacing existing session artifacts. - The configurator Session tab manages recent named sessions recorded when named-session targets are opened or saved from the CLI, daemon, or overlay. It can rename catalog labels, reveal files, and forget metadata without touching files. Clear Tool State removes only the saved tool layer; Clear Saved Data removes session files. Duplicate, Move, Clear Tool State, and Clear are disabled while an overlay, manually started daemon, or background service is active. diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ccb1843a..727cece2 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -2114,7 +2114,7 @@ Use the CLI helpers for quick maintenance: - `wayscriber --daemon --session-file ~/Documents/lecture-04.wayscriber-session` starts a daemon whose overlay activations use that named session target, the daemon's home session. - `wayscriber --daemon-toggle --session-file ~/Documents/meeting.wayscriber-session` asks the running daemon to launch a hidden overlay with that named session target. It applies to that activation only. If the overlay is already visible with a different target, hide it before switching. -The daemon's overlay keeps its session across hide and show. After **Open** or **Save As** switches the overlay to another session, the next activation continues in that session, until the overlay returns to the daemon's home session: the file given with `--daemon --session-file`, or the configured default session without one. A `--daemon-toggle --session-file` request takes precedence for its activation. If the remembered file was moved or deleted, the overlay opens the home session instead and says so. The daemon keeps this only while it runs; a restarted daemon starts at home. +The daemon's overlay keeps its session across hide and show. After **Open** or **Save As** switches the overlay to another session, the next activation continues in that session, until the overlay returns to the daemon's home session: the file given with `--daemon --session-file`, or the configured default session without one. The Session popover's **Back to ** or **Default session** button returns home. A `--daemon-toggle --session-file` request takes precedence for its activation. If the remembered file was moved or deleted, the overlay opens the home session instead and says so. The daemon keeps this only while it runs; a restarted daemon starts at home. - `wayscriber --session-info --session-file `, `wayscriber --clear-session --session-file `, and `wayscriber --clear-tool-state --session-file ` target only that named file. Config values seed startup defaults. When `restore_tool_state = true`, the saved session tool state is applied after those defaults, so edits such as `[arrow] head_at_end = true` can appear ignored if the session snapshot still stores an older arrow setting. Run `wayscriber --clear-tool-state` (or add `--session-file ` for a named session) to make config defaults apply on the next startup without deleting saved boards. In a running overlay, Command Palette -> Reset Tool Defaults clears the saved layer for the active session and immediately applies config defaults to the current tools so the next autosave keeps those defaults. @@ -2127,6 +2127,7 @@ The overlay Session panel lives in the top strip's overflow **"Session..."** pop - `Save As` writes the current overlay to another named session and switches the active target. It appends `.wayscriber-session` when no extension is supplied and asks before replacing existing session artifacts. - `Info` reports the active session file size, board shape counts, and history status. - `Clear` writes a durable empty session boundary for the active target. +- `Back to ` returns to the home session: the file the overlay or daemon started with. Without one it reads `Default session` and returns to the configured default session. Like `Open`, it saves dirty current data first and switches only once home has loaded. It is disabled while home is already active. Hide it with the `side.session.home` toolbar item. - Recent session rows reopen other named sessions. If a recent target is missing, Wayscriber removes that stale catalog entry after the failed open. - `Manager` opens the configurator. Overlay Open/Save As dialogs use `zenity` or `kdialog`. diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 26032b38..2ff908cf 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -539,18 +539,20 @@ capture suppression operates on the paired resources without runtime pairing che **Modules:** - `src/session/`: target options, primary-file validation, snapshot load/save, sidecars, clear/recovery markers, saved tool-state reset, locks, catalog metadata, and inactive file operations. - `src/backend/wayland/session/`: runtime Open, Save As, Clear, and saved tool-state reset transactions for the active overlay. -- `src/backend/wayland/state/toolbar/events/session.rs`: overlay Session popover routing for Open, Save As, Info, Clear, recent sessions, and configurator launch. -- `src/daemon/`: accepts daemon-toggle requests that carry an optional named session target. +- `src/backend/wayland/state/toolbar/events/session.rs`: overlay Session popover routing for Open, Save As, return home, Info, Clear, recent sessions, and configurator launch. +- `src/backend/wayland/session/home.rs`: the overlay's home session, the remembered session a daemon launch carries, and the session reports sent back to the daemon. +- `src/daemon/`: accepts daemon-toggle requests that carry an optional named session target, and remembers the session its overlay last reported across hide and show. **Flow:** 1. CLI `--session-file` creates a named target instead of using configured storage. Named targets force persistence for that run, reject `--no-resume-session`, require an existing parent directory for foreground/open flows, and reject directories, symlinks, and special files. 2. Backend startup builds `SessionOptions` from config plus any named target, then session loading restores boards/history/tool state before rendering begins. 3. Runtime Open first saves dirty current data when needed, loads the candidate named session without mutating it, replaces board state only after a valid load, and records the open in the named-session catalog. 4. Runtime Save As validates the target, prompts before replacing existing artifacts, writes the snapshot, switches the active target, and records the save in the catalog. -5. Runtime Clear writes a durable empty-session boundary so older backup or recovery artifacts do not restore stale drawings. -6. Runtime saved tool-state reset clears the persisted tool layer for the active session and applies config-derived tool defaults in memory so autosave does not restore stale values. -7. Offline CLI maintenance can inspect sessions, clear all saved data, or clear only persisted tool state so config defaults seed the next startup without deleting boards. -8. The configurator reads the same catalog for inactive-session management: rename/reveal/forget metadata, duplicate primary files, move non-lock sidecars, clear saved tool state, and clear saved data when daemon/overlay locks are absent. +5. Returning home saves dirty current data the same way, then loads the home session as a launch would. A daemon overlay reports each committed target change, so the daemon starts the next overlay in that session; a remembered session that no longer exists falls back to home. +6. Runtime Clear writes a durable empty-session boundary so older backup or recovery artifacts do not restore stale drawings. +7. Runtime saved tool-state reset clears the persisted tool layer for the active session and applies config-derived tool defaults in memory so autosave does not restore stale values. +8. Offline CLI maintenance can inspect sessions, clear all saved data, or clear only persisted tool state so config defaults seed the next startup without deleting boards. +9. The configurator reads the same catalog for inactive-session management: rename/reveal/forget metadata, duplicate primary files, move non-lock sidecars, clear saved tool state, and clear saved data when daemon/overlay locks are absent. --- diff --git a/examples/session-manager.md b/examples/session-manager.md index 5bd5c755..c5676451 100644 --- a/examples/session-manager.md +++ b/examples/session-manager.md @@ -47,6 +47,9 @@ Open Wayscriber with any persisted session target, then use the top strip overfl - `Info` reports the active session file size, board shape counts, and history status. - `Clear` writes a durable empty session boundary for the active target. +- `Back to `, or `Default session` without a startup session file, + saves the current session and returns to the session the overlay or daemon + started with. It is disabled while that session is already active. - Recent session rows reopen other named sessions. - `Manager` opens the configurator. diff --git a/src/backend/wayland/session.rs b/src/backend/wayland/session.rs index 4d3dea38..b1aa6571 100644 --- a/src/backend/wayland/session.rs +++ b/src/backend/wayland/session.rs @@ -227,6 +227,16 @@ impl SessionState { self.notified_failure = false; } + /// Continues without persistence: home is a default session that is not + /// saved. + pub(in crate::backend::wayland) fn commit_without_persistence(&mut self) { + self.advance_target_epoch(); + self.options = None; + self.loaded = true; + self.loaded_board_data = false; + self.mark_clean_after_load(); + } + pub(in crate::backend::wayland) fn commit_runtime_clear(&mut self, now: Instant) { self.loaded = true; self.loaded_board_data = false; @@ -595,6 +605,8 @@ mod home; mod persistence; mod runtime; +#[cfg(test)] +pub(in crate::backend::wayland) use home::HomeSession; pub(in crate::backend::wayland) use home::{SessionHome, SessionLaunch, session_target}; pub(in crate::backend::wayland) use persistence::{ PersistenceCompletion, PersistenceController, PersistenceOperation, PersistenceOutcome, @@ -602,8 +614,8 @@ pub(in crate::backend::wayland) use persistence::{ }; pub(in crate::backend::wayland) use runtime::{ - ExplicitSessionTransaction, SessionCommand, SessionCommandReport, SessionTransaction, - TransactionStep, + ExplicitSessionTransaction, RuntimeHomeSessionReport, SessionCommand, SessionCommandReport, + SessionTransaction, TransactionStep, }; #[cfg(test)] pub(in crate::backend::wayland) use runtime::{ diff --git a/src/backend/wayland/session/driver/tests.rs b/src/backend/wayland/session/driver/tests.rs index dacdd887..481049f1 100644 --- a/src/backend/wayland/session/driver/tests.rs +++ b/src/backend/wayland/session/driver/tests.rs @@ -707,6 +707,104 @@ fn failed_open_and_save_as_announce_nothing() { } } +fn configured_home(base: &std::path::Path) -> stored_session::SessionOptions { + let mut options = stored_session::SessionOptions::new(base.join("configured"), "home"); + options.persist_transparent = true; + options +} + +#[test] +fn returning_home_saves_the_current_session_then_loads_home_like_a_launch() { + let temp = crate::test_temp::tempdir().unwrap(); + let current = named_options(temp.path(), "current"); + let home = configured_home(temp.path()); + stored_session::save_snapshot(&sample_snapshot(), &home).unwrap(); + let mut input = test_input_state(); + add_line(&mut input, 51); + input.mark_session_dirty(); + let mut session = SessionState::new(Some(current.clone())); + let measurer = TextMeasurer::default(); + let (persistence, worker) = PersistenceController::controlled_for_test(); + let mut runtime = CommandRuntime::new(&mut input, &measurer, &mut session, persistence); + + start_session_command( + &mut runtime, + SessionCommand::OpenHome(Some(Box::new(home.clone()))), + ) + .unwrap(); + worker.complete_next(); // save the current session + runtime.receive(); + assert_eq!(loaded_line_x2(¤t), 51); + assert!(runtime.reports.is_empty()); + worker.complete_next(); // load home + runtime.receive(); + + assert!(runtime.errors.is_empty()); + let [SessionCommandReport::Home(report)] = runtime.reports.as_slice() else { + panic!( + "expected a home report, got {} reports", + runtime.reports.len() + ); + }; + assert_eq!( + report.options.as_ref().map(|options| &options.target), + Some(&home.target) + ); + assert!(matches!( + report.outcome, + Some(stored_session::LoadSnapshotOutcome::Loaded(_)) + )); + // The runtime applies home; until then the canvas and target stay put. + assert_eq!(runtime.session.options().unwrap().target, current.target); + assert_eq!(runtime.input.boards.active_frame().shapes.len(), 1); +} + +#[test] +fn returning_home_is_refused_when_the_canvas_changes_while_home_loads() { + let temp = crate::test_temp::tempdir().unwrap(); + let current = named_options(temp.path(), "current"); + let home = configured_home(temp.path()); + let mut input = test_input_state(); + let mut session = SessionState::new(Some(current.clone())); + let measurer = TextMeasurer::default(); + let (persistence, worker) = PersistenceController::controlled_for_test(); + let mut runtime = CommandRuntime::new(&mut input, &measurer, &mut session, persistence); + + start_session_command(&mut runtime, SessionCommand::OpenHome(Some(Box::new(home)))).unwrap(); + add_line(runtime.input, 77); + runtime.input.mark_session_dirty(); + worker.complete_next(); // load home + runtime.receive(); + + assert!(runtime.reports.is_empty()); + assert!( + runtime.errors[0] + .to_string() + .contains("session was edited while the command was pending"), + "{:?}", + runtime.errors + ); + assert_eq!(runtime.session.options().unwrap().target, current.target); +} + +#[test] +fn returning_to_a_home_without_persistence_loads_nothing() { + let temp = crate::test_temp::tempdir().unwrap(); + let mut input = test_input_state(); + let mut session = SessionState::new(Some(named_options(temp.path(), "current"))); + let measurer = TextMeasurer::default(); + let (persistence, worker) = PersistenceController::controlled_for_test(); + let mut runtime = CommandRuntime::new(&mut input, &measurer, &mut session, persistence); + + start_session_command(&mut runtime, SessionCommand::OpenHome(None)).unwrap(); + + assert!(!worker.has_request()); + let [SessionCommandReport::Home(report)] = runtime.reports.as_slice() else { + panic!("expected a home report"); + }; + assert!(report.options.is_none() && report.outcome.is_none()); +} + #[test] fn clear_completion_refreshes_consumer_seeds() { let temp = crate::test_temp::tempdir().unwrap(); diff --git a/src/backend/wayland/session/home.rs b/src/backend/wayland/session/home.rs index 42da452f..02e4e2a0 100644 --- a/src/backend/wayland/session/home.rs +++ b/src/backend/wayland/session/home.rs @@ -90,6 +90,9 @@ pub(in crate::backend::wayland) struct SessionHome { /// The target the daemon last learned this overlay is in, by launching it /// there or from a report. reported: SessionTarget, + /// Whether `reported` is home. Kept rather than worked out when asked, + /// since the Session menu asks on every redraw. + at_home: bool, } impl SessionHome { @@ -100,6 +103,7 @@ impl SessionHome { startup: SessionTarget, ) -> Self { Self { + at_home: is_home(&launch.home, &startup), home: launch.home, options, unchecked_preferred: launch.preferred, @@ -117,38 +121,50 @@ impl SessionHome { self.unchecked_preferred.take() } - /// How the Session menu and notices name home. + /// The name of a named home session; `None` for the default session. + pub(in crate::backend::wayland) fn name(&self) -> Option { + self.home.file().map(session_display_name) + } + + /// How notices name home. pub(in crate::backend::wayland) fn label(&self) -> String { - match &self.home { - HomeSession::Default => "the default session".to_owned(), - HomeSession::Named(path) => session_display_name(path), - } + self.name() + .unwrap_or_else(|| "the default session".to_owned()) } - /// What to report now that the overlay is in `target`, or `None` when the - /// daemon already knows. Home is reported as such even when it is a named - /// file, so the daemon never remembers home as a session of its own. - pub(in crate::backend::wayland) fn report_for( + /// Whether the overlay is in its home session, as of the last commit. + pub(in crate::backend::wayland) fn is_at_home(&self) -> bool { + self.at_home + } + + /// Records that the overlay is now in `target`, returning what to report, + /// or `None` when the daemon already knows. Home is reported as such even + /// when it is a named file, so the daemon never remembers home as a + /// session of its own. + pub(in crate::backend::wayland) fn commit_target( &mut self, target: SessionTarget, ) -> Option { if target == self.reported { return None; } + self.at_home = is_home(&self.home, &target); let report = match &target { - SessionTarget::NamedFile(path) if !self.is_home_file(path) => { - ReportedSession::Named(path.clone()) - } + SessionTarget::NamedFile(path) if !self.at_home => ReportedSession::Named(path.clone()), _ => ReportedSession::Home, }; self.reported = target; Some(report) } +} - fn is_home_file(&self, path: &Path) -> bool { - self.home - .file() - .is_some_and(|home| session_paths_match(home, path)) +fn is_home(home: &HomeSession, target: &SessionTarget) -> bool { + match (home, target) { + (HomeSession::Default, SessionTarget::Configured) => true, + (HomeSession::Named(home), SessionTarget::NamedFile(path)) => { + session_paths_match(home, path) + } + _ => false, } } diff --git a/src/backend/wayland/session/home/tests.rs b/src/backend/wayland/session/home/tests.rs index 7fb3c99e..afb6ad8d 100644 --- a/src/backend/wayland/session/home/tests.rs +++ b/src/backend/wayland/session/home/tests.rs @@ -133,14 +133,14 @@ fn only_the_first_load_checks_the_preferred_session() { fn only_a_changed_session_is_reported() { let mut home = home_session(named(HOME), Some(PREFERRED), file(PREFERRED)); - assert_eq!(home.report_for(file(PREFERRED)), None); + assert_eq!(home.commit_target(file(PREFERRED)), None); let other = "/sessions/d.wayscriber-session"; assert_eq!( - home.report_for(file(other)), + home.commit_target(file(other)), Some(ReportedSession::Named(PathBuf::from(other))) ); - assert_eq!(home.report_for(file(other)), None); + assert_eq!(home.commit_target(file(other)), None); } #[test] @@ -150,17 +150,17 @@ fn home_is_reported_as_home_even_as_a_named_file() { let alias = "/sessions/./home.wayscriber-session"; assert_eq!( - named_home.report_for(file(alias)), + named_home.commit_target(file(alias)), Some(ReportedSession::Home) ); let mut default_home = home_session(HomeSession::Default, None, file(PREFERRED)); assert_eq!( - default_home.report_for(SessionTarget::Configured), + default_home.commit_target(SessionTarget::Configured), Some(ReportedSession::Home) ); assert_eq!( - default_home.report_for(file(HOME)), + default_home.commit_target(file(HOME)), Some(ReportedSession::Named(PathBuf::from(HOME))) ); } @@ -169,3 +169,34 @@ fn home_is_reported_as_home_even_as_a_named_file() { fn a_run_without_persistence_is_in_the_default_session() { assert_eq!(session_target(None), SessionTarget::Configured); } + +#[test] +fn the_overlay_knows_whether_it_is_home() { + let mut named_home = home_session(named(HOME), Some(PREFERRED), file(PREFERRED)); + assert!(!named_home.is_at_home()); + named_home.commit_target(file("/sessions/./home.wayscriber-session")); + assert!(named_home.is_at_home()); + named_home.commit_target(file(PREFERRED)); + assert!(!named_home.is_at_home()); + assert!(home_session(named(HOME), None, file(HOME)).is_at_home()); + + let mut default_home = home_session(HomeSession::Default, None, file(PREFERRED)); + assert!(!default_home.is_at_home()); + default_home.commit_target(SessionTarget::Configured); + assert!(default_home.is_at_home()); + assert!(home_session(HomeSession::Default, None, SessionTarget::Configured).is_at_home()); +} + +#[test] +fn only_a_named_home_has_a_name() { + assert_eq!( + home_session(named(HOME), None, file(HOME)) + .name() + .as_deref(), + Some("home.wayscriber-session") + ); + assert_eq!( + home_session(HomeSession::Default, None, SessionTarget::Configured).name(), + None + ); +} diff --git a/src/backend/wayland/session/runtime.rs b/src/backend/wayland/session/runtime.rs index 5a2f2bb2..206531eb 100644 --- a/src/backend/wayland/session/runtime.rs +++ b/src/backend/wayland/session/runtime.rs @@ -11,6 +11,16 @@ pub(in crate::backend::wayland) struct RuntimeOpenSessionReport { pub catalog_error: Option, } +/// A return to the home session that saved the current session and loaded +/// home, for the runtime to apply. +#[derive(Debug)] +pub(in crate::backend::wayland) struct RuntimeHomeSessionReport { + /// Home's options; `None` when home has persistence disabled. + pub options: Option, + /// What loading home found; `None` without persistence. + pub outcome: Option, +} + #[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(in crate::backend::wayland) struct RuntimeSaveAsSessionReport { diff --git a/src/backend/wayland/session/runtime/transaction.rs b/src/backend/wayland/session/runtime/transaction.rs index 218dd98d..89a1b757 100644 --- a/src/backend/wayland/session/runtime/transaction.rs +++ b/src/backend/wayland/session/runtime/transaction.rs @@ -6,6 +6,9 @@ mod phases; #[derive(Debug)] pub(in crate::backend::wayland) enum SessionCommand { Open(PathBuf), + /// Return to the home session, whose options for the current output this + /// carries; `None` when home has persistence disabled. + OpenHome(Option>), SaveAs(PathBuf, SaveAsOverwrite), CheckOverwrite(PathBuf), Clear, @@ -16,6 +19,7 @@ pub(in crate::backend::wayland) enum SessionCommand { pub(in crate::backend::wayland) enum SessionCommandReport { Open(RuntimeOpenSessionReport), + Home(RuntimeHomeSessionReport), SaveAs(RuntimeSaveAsSessionReport), Overwrite(PathBuf, bool), Clear(RuntimeClearSessionReport), @@ -36,6 +40,7 @@ enum Phase { SaveCurrent, Load, RecordOpen, + LoadHome, SaveAsPreflight, SaveAs, Clear, @@ -117,6 +122,7 @@ impl ExplicitSessionTransaction { Phase::SaveCurrent => self.complete_save_current(context, outcome), Phase::Load => self.complete_load(context, outcome), Phase::RecordOpen => self.complete_record_open(context, outcome), + Phase::LoadHome => self.complete_load_home(context, outcome), Phase::SaveAsPreflight => self.complete_save_as_preflight(context, outcome), Phase::SaveAs => self.complete_save_as(context, outcome), Phase::Clear => self.complete_clear(context, outcome), @@ -196,6 +202,22 @@ impl ExplicitSessionTransaction { }, ) } + // Home loads the way a launch would, so its recovery, clear and + // tool-restore rules apply rather than those of a runtime Open. + SessionCommand::OpenHome(Some(options)) => { + let options = (**options).clone(); + self.capture_input_generation(context); + self.work( + Phase::LoadHome, + PersistenceOperation::LoadConfigured { options }, + ) + } + SessionCommand::OpenHome(None) => Ok(TransactionStep::Complete(Box::new( + SessionCommandReport::Home(RuntimeHomeSessionReport { + options: None, + outcome: None, + }), + ))), SessionCommand::SaveAs(_, _) => Ok(TransactionStep::Complete(Box::new( SessionCommandReport::SaveAs(RuntimeSaveAsSessionReport { previous_path: self.current_path(), diff --git a/src/backend/wayland/session/runtime/transaction/phases.rs b/src/backend/wayland/session/runtime/transaction/phases.rs index 99e80d0a..93037b27 100644 --- a/src/backend/wayland/session/runtime/transaction/phases.rs +++ b/src/backend/wayland/session/runtime/transaction/phases.rs @@ -57,6 +57,13 @@ impl ExplicitSessionTransaction { }, ) } + SessionCommand::OpenHome(_) => { + cancel_pending_output_transition_for_explicit_target( + context.session, + "Return to the home session", + ); + self.save_current_or_continue(context) + } SessionCommand::Clear => { self.capture_input_generation(context); self.work( @@ -165,6 +172,27 @@ impl ExplicitSessionTransaction { ))) } + /// Home is applied by the runtime, which loads it like a launch would; the + /// transaction only hands over what the load found. + pub(super) fn complete_load_home( + &mut self, + _context: &mut SessionTransaction<'_>, + outcome: Option, + ) -> Result { + let PersistenceOutcome::Load(load) = required_outcome(outcome)? else { + return Err(anyhow!("unexpected home-session load outcome")); + }; + let SessionCommand::OpenHome(Some(options)) = &self.command else { + unreachable!() + }; + Ok(TransactionStep::Complete(Box::new( + SessionCommandReport::Home(RuntimeHomeSessionReport { + options: Some((**options).clone()), + outcome: Some(load), + }), + ))) + } + pub(super) fn complete_save_as_preflight( &mut self, context: &mut SessionTransaction<'_>, diff --git a/src/backend/wayland/state/core/output/session_ops.rs b/src/backend/wayland/state/core/output/session_ops.rs index 9ac37522..ce3c3091 100644 --- a/src/backend/wayland/state/core/output/session_ops.rs +++ b/src/backend/wayland/state/core/output/session_ops.rs @@ -64,7 +64,7 @@ impl WaylandState { }) } - pub(super) fn handle_session_load_outcome_for_options( + pub(in crate::backend::wayland::state) fn handle_session_load_outcome_for_options( &mut self, outcome: session::LoadSnapshotOutcome, options: &session::SessionOptions, diff --git a/src/backend/wayland/state/core/session_home.rs b/src/backend/wayland/state/core/session_home.rs index 5d3f00d6..fc1f960e 100644 --- a/src/backend/wayland/state/core/session_home.rs +++ b/src/backend/wayland/state/core/session_home.rs @@ -65,7 +65,7 @@ impl WaylandState { /// switch stands. pub(in crate::backend::wayland) fn session_target_committed(&mut self) { let target = session_target(self.session.options()); - let Some(session) = self.session_home.report_for(target) else { + let Some(session) = self.session_home.commit_target(target) else { return; }; if let Err(error) = publish_session_from_environment(&session) { diff --git a/src/backend/wayland/state/toolbar/events.rs b/src/backend/wayland/state/toolbar/events.rs index db386b47..f0bac09f 100644 --- a/src/backend/wayland/state/toolbar/events.rs +++ b/src/backend/wayland/state/toolbar/events.rs @@ -110,7 +110,7 @@ impl WaylandState { // toolbar snapshot is built between canvas renders, and before the // first one, so a published value would lag or not exist yet. snapshot.spotlight_magnifier_source = Some(self.current_spotlight_magnifier_source()); - populate_session_snapshot(&mut snapshot, self.session.options()); + populate_session_snapshot(&mut snapshot, self.session.options(), &self.session_home); snapshot.runtime_ui_persistence = self .preferences .runtime_ui() diff --git a/src/backend/wayland/state/toolbar/events/session.rs b/src/backend/wayland/state/toolbar/events/session.rs index ef2f9e16..c56f20cf 100644 --- a/src/backend/wayland/state/toolbar/events/session.rs +++ b/src/backend/wayland/state/toolbar/events/session.rs @@ -10,7 +10,10 @@ use wayland_client::{Connection, QueueHandle}; pub(super) fn populate_session_snapshot( snapshot: &mut ToolbarSnapshot, options: Option<&crate::session::SessionOptions>, + home: &crate::backend::wayland::session::SessionHome, ) { + snapshot.home_session_name = home.name(); + snapshot.at_home_session = home.is_at_home(); let active_path = options.map(|options| options.session_file_path()); snapshot.active_session_name = active_path.as_deref().map(session_display_name); // Recents are only read (from the catalog on disk) while the top strip's @@ -104,6 +107,7 @@ fn recent_session_snapshots( } mod dialog; +mod home; pub(in crate::backend::wayland::state) use dialog::SessionFileDialogController; pub(super) use dialog::{SessionFileDialogMode, ensure_save_as_extension}; @@ -128,6 +132,10 @@ impl WaylandState { self.handle_toolbar_open_session_path(path); true } + ToolbarEvent::OpenHomeSession => { + self.handle_toolbar_open_home_session(); + true + } ToolbarEvent::SaveSessionAs => { self.handle_toolbar_save_session_as(conn, qh); true @@ -419,6 +427,7 @@ impl WaylandState { self.set_session_toolbar_info(format!("Opened session {name}")); } } + SessionCommandReport::Home(report) => self.finish_open_home_session(report), SessionCommandReport::SaveAs(report) => { self.clear_toolbar_save_as_overwrite_prompt(); self.set_session_toolbar_info(format!( @@ -503,6 +512,7 @@ impl WaylandState { } let prefix = match command { SessionCommand::Open(_) => "Open session failed", + SessionCommand::OpenHome(_) => "Return to the home session failed", SessionCommand::SaveAs(..) | SessionCommand::CheckOverwrite(_) => "Save session failed", SessionCommand::Clear => "Clear session failed", SessionCommand::ClearTools(_) => "Failed to reset tool defaults", @@ -612,8 +622,34 @@ fn dialog_frame_accepted(phase: DialogFramePhase, outcome: RenderOutcome) -> boo #[cfg(test)] mod tests { - use super::{DialogFramePhase, dialog_frame_accepted}; + use super::{DialogFramePhase, dialog_frame_accepted, populate_session_snapshot}; + use crate::backend::wayland::session::{SessionHome, SessionLaunch}; use crate::backend::wayland::state::RenderOutcome; + use crate::backend::wayland::state::toolbar::ToolbarSnapshot; + use crate::session::SessionTarget; + + #[test] + fn the_session_menu_learns_home_and_whether_it_is_active() { + let input = crate::input::state::test_support::make_test_input_state(); + let mut snapshot = ToolbarSnapshot::from_input_with_bindings(&input, Default::default()); + let home = std::path::PathBuf::from("/sessions/home.wayscriber-session"); + let away = SessionHome::new( + SessionLaunch { + home: crate::backend::wayland::session::HomeSession::Named(home), + preferred: None, + }, + None, + SessionTarget::NamedFile("/sessions/b.wayscriber-session".into()), + ); + + populate_session_snapshot(&mut snapshot, None, &away); + + assert_eq!( + snapshot.home_session_name.as_deref(), + Some("home.wayscriber-session") + ); + assert!(!snapshot.at_home_session); + } #[test] fn dialog_entry_requires_a_committed_frame() { diff --git a/src/backend/wayland/state/toolbar/events/session/home.rs b/src/backend/wayland/state/toolbar/events/session/home.rs new file mode 100644 index 00000000..c08cc98e --- /dev/null +++ b/src/backend/wayland/state/toolbar/events/session/home.rs @@ -0,0 +1,76 @@ +use super::*; +use crate::backend::wayland::session::RuntimeHomeSessionReport; +use crate::session::{SessionOptions, SessionSnapshot}; + +impl WaylandState { + /// Returns to the home session the way Open switches session: the current + /// session is saved first, and nothing changes unless home loads. + pub(super) fn handle_toolbar_open_home_session(&mut self) { + self.clear_toolbar_save_as_overwrite_prompt(); + let command = SessionCommand::OpenHome(self.home_session_options().map(Box::new)); + if let Err(error) = self.start_session_command(command) { + self.report_session_command_error("Return to the home session failed", &error); + } + } + + /// Home's options for the output the overlay is on now, not those of the + /// named session it is leaving. + fn home_session_options(&self) -> Option { + let mut options = self.session_home.options()?.clone(); + let output_identity = self + .surface + .current_output() + .as_ref() + .and_then(|output| self.output_identity_for(output)); + options.set_output_identity(output_identity.as_deref()); + Some(options) + } + + pub(super) fn finish_open_home_session(&mut self, report: RuntimeHomeSessionReport) { + let applied = match (report.options, report.outcome) { + (Some(options), Some(outcome)) => { + let loaded_board_data = outcome.has_board_data(); + self.handle_session_load_outcome_for_options(outcome, &options, "home session") + .map(|()| { + self.input_state + .set_session_preflight_options(Some(options.clone())); + self.session + .commit_output_options(options, loaded_board_data); + }) + } + _ => self.leave_persistence_for_home(), + }; + if let Err(error) = applied { + self.report_session_command_error("Return to the home session failed", &error); + return; + } + + self.session_target_committed(); + self.set_session_toolbar_info(format!("Returned to {}", self.session_home.label())); + } + + /// Home has persistence disabled: the run continues on an empty canvas + /// that is not saved, as it would have started. + fn leave_persistence_for_home(&mut self) -> Result<()> { + let current = self + .session_options() + .cloned() + .context("no session to return home from")?; + let empty = SessionSnapshot { + active_board_id: self.input_state.board_id().to_string(), + boards: Vec::new(), + tool_state: None, + }; + crate::session::apply_snapshot_replacing_boards( + &mut self.input_state, + self.render.text_measurer(), + empty, + ¤t, + )?; + self.input_state.set_session_preflight_options(None); + self.input_state.clear_session_dirty(); + self.session.commit_without_persistence(); + self.refresh_runtime_ui_config_seeds(); + Ok(()) + } +} diff --git a/src/backend/wayland/state/toolbar/events/tests.rs b/src/backend/wayland/state/toolbar/events/tests.rs index 8827cca2..aeb0bc55 100644 --- a/src/backend/wayland/state/toolbar/events/tests.rs +++ b/src/backend/wayland/state/toolbar/events/tests.rs @@ -210,6 +210,7 @@ fn runtime_toolbar_events_do_not_directly_save_config() { ToolbarEvent::ApplyPreset(1), ToolbarEvent::OpenSession, ToolbarEvent::OpenRecentSession(std::path::PathBuf::from("/tmp/recent.wayscriber-session")), + ToolbarEvent::OpenHomeSession, ToolbarEvent::SaveSessionAs, ToolbarEvent::SaveSessionAsConfirm(std::path::PathBuf::from( "/tmp/existing.wayscriber-session", @@ -1023,6 +1024,7 @@ fn session_popover_survives_its_own_controls_and_dismisses_on_everything_else() for spared in [ ToolbarEvent::OpenSession, ToolbarEvent::OpenRecentSession(PathBuf::from("/tmp/recent.wayscriber-session")), + ToolbarEvent::OpenHomeSession, ToolbarEvent::SaveSessionAs, ToolbarEvent::SaveSessionAsConfirm(PathBuf::from("/tmp/existing.wayscriber-session")), ToolbarEvent::SaveSessionAsCancel, diff --git a/src/backend/wayland/toolbar/view/top/menus.rs b/src/backend/wayland/toolbar/view/top/menus.rs index 80bb71f2..81882dec 100644 --- a/src/backend/wayland/toolbar/view/top/menus.rs +++ b/src/backend/wayland/toolbar/view/top/menus.rs @@ -362,6 +362,23 @@ fn session_menu_content(snapshot: &ToolbarSnapshot) -> Option> { y += grid.height; } + if let Some(home) = model.home.as_ref() { + y += MENU_GAP; + nodes.push(text_button( + "top.menu.session.home".to_owned(), + (0.0, y, MENU_CONTENT_W, MENU_BUTTON_H), + LabelSpec::new(truncate_middle(&home.label, 30), MENU_LABEL_FONT, true), + if home.enabled { + ButtonStyle::plain() + } else { + ButtonStyle::disabled() + }, + home.enabled + .then(|| Interaction::click(home.event(), Some(home.label.clone()))), + )); + y += MENU_BUTTON_H; + } + for (index, recent) in model.recents.iter().enumerate() { y += MENU_GAP; let tooltip_path = recent.path.display().to_string(); diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs index f5b199b0..6f05f956 100644 --- a/src/backend/wayland/toolbar/view/top/tests.rs +++ b/src/backend/wayland/toolbar/view/top/tests.rs @@ -1931,6 +1931,13 @@ fn session_popover_re_hosts_the_session_pane_content() { assert_session_nodes_inside_panel(&tree); assert_session_popover_input_rect(&snapshot, &tree, w, h); + // Away from home, the way back is offered. + snapshot.home_session_name = Some("home.wayscriber-session".to_string()); + snapshot.at_home_session = false; + let tree = build(&snapshot); + assert_session_popover_model(&tree, &snapshot); + assert_session_nodes_inside_panel(&tree); + // A pending Save-As overwrite swaps the button grid for the // confirmation, exactly like the pane. snapshot.pending_save_as_overwrite_path = @@ -1974,6 +1981,24 @@ fn assert_session_popover_model(tree: &WidgetTree, snapshot: &ToolbarSnapshot) { .expect("recent row"); assert_eq!(node.interact.as_ref().unwrap().event, recent.event()); } + let home = model.home.as_ref().expect("home row"); + let node = tree + .node_by_id(&"top.menu.session.home".into()) + .expect("home row"); + match &node.kind { + WidgetKind::TextButton { label, style } => { + assert_eq!( + label.text, + crate::ui::toolbar::session_format::truncate_middle(&home.label, 30) + ); + assert_eq!(style.disabled, !home.enabled); + } + other => panic!("home row kind, got {other:?}"), + } + assert_eq!( + node.interact.as_ref().map(|interact| &interact.event), + home.enabled.then(|| home.event()).as_ref() + ); // Meta labels are decor; this popover has no collapsible header. assert!(tree.node_by_id(&"top.menu.session.name".into()).is_some()); assert!(tree.node_by_id(&"top.menu.session.path".into()).is_some()); diff --git a/src/config/types/toolbar/ids.rs b/src/config/types/toolbar/ids.rs index 3ade29a4..68544e06 100644 --- a/src/config/types/toolbar/ids.rs +++ b/src/config/types/toolbar/ids.rs @@ -126,6 +126,7 @@ pub const SIDE_SETTINGS_ABOUT: ToolbarItemId = ToolbarItemId::from_known("side.s pub const SIDE_SESSION_OPEN: ToolbarItemId = ToolbarItemId::from_known("side.session.open"); pub const SIDE_SESSION_SAVE_AS: ToolbarItemId = ToolbarItemId::from_known("side.session.save-as"); +pub const SIDE_SESSION_HOME: ToolbarItemId = ToolbarItemId::from_known("side.session.home"); pub const SIDE_SESSION_INFO: ToolbarItemId = ToolbarItemId::from_known("side.session.info"); pub const SIDE_SESSION_CLEAR: ToolbarItemId = ToolbarItemId::from_known("side.session.clear"); pub const SIDE_SESSION_MANAGER: ToolbarItemId = ToolbarItemId::from_known("side.session.manager"); diff --git a/src/config/types/toolbar/items/definitions.rs b/src/config/types/toolbar/items/definitions.rs index 8bdff410..dd3288c5 100644 --- a/src/config/types/toolbar/items/definitions.rs +++ b/src/config/types/toolbar/items/definitions.rs @@ -452,6 +452,13 @@ const TOOLBAR_ITEM_DEFINITIONS: &[ToolbarItemDefinition] = &[ Session, Some(ToolbarGroupId::Session), ), + item( + ids::SIDE_SESSION_HOME, + "Return to home session", + Popover, + Session, + Some(ToolbarGroupId::Session), + ), item( ids::SIDE_SESSION_INFO, "Session info", diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs index 4d8e7f50..9a3f520f 100644 --- a/src/input/state/core/toolbar/apply/mod.rs +++ b/src/input/state/core/toolbar/apply/mod.rs @@ -362,6 +362,7 @@ impl InputState { ToolbarEvent::ClearPreset(slot) => self.apply_toolbar_clear_preset(slot), ToolbarEvent::OpenSession | ToolbarEvent::OpenRecentSession(_) + | ToolbarEvent::OpenHomeSession | ToolbarEvent::SaveSessionAs | ToolbarEvent::SaveSessionAsConfirm(_) | ToolbarEvent::SaveSessionAsCancel diff --git a/src/toolbar_gtk/view/sections/session_pane.rs b/src/toolbar_gtk/view/sections/session_pane.rs index 6120b509..f4dedbb5 100644 --- a/src/toolbar_gtk/view/sections/session_pane.rs +++ b/src/toolbar_gtk/view/sections/session_pane.rs @@ -1,6 +1,7 @@ //! Session pane: active-session meta labels, the Open / Save As / Info / //! Clear / Manager grid (replaced by the Save-As overwrite confirmation -//! while one is pending), and the recent-session list. +//! while one is pending), the way back to the home session, and the +//! recent-session list. use gtk4::prelude::*; @@ -54,6 +55,9 @@ pub(in crate::toolbar_gtk) fn build_popover_content( column.append(&grid); } + if let Some(home) = session.home.as_ref() { + column.append(&home_button(ctx, home)); + } for recent in &session.recents { column.append(&recent_row(ctx, recent)); } @@ -131,6 +135,22 @@ fn overwrite_confirmation_rows( rows } +fn home_button(ctx: &SectionCtx, home: &model::session::ToolbarSessionHome) -> gtk4::Button { + let button = gtk4::Button::new(); + button.set_size_request(-1, ctx.px(24.0)); + let label = gtk4::Label::new(Some(&home.label)); + label.set_ellipsize(gtk4::pango::EllipsizeMode::Middle); + button.set_child(Some(&label)); + button.set_tooltip_text(Some(&home.label)); + button.set_sensitive(home.enabled); + let sender = ctx.feedback.clone(); + let event = home.event(); + button.connect_clicked(move |_| { + send_event(&sender, event.clone()); + }); + button +} + fn recent_row(ctx: &SectionCtx, recent: &model::ToolbarSessionRecent) -> gtk4::Button { let button = gtk4::Button::new(); button.set_size_request(-1, ctx.px(22.0)); diff --git a/src/toolbar_gtk/view/top_bar.rs b/src/toolbar_gtk/view/top_bar.rs index f88617d7..ab0c527d 100644 --- a/src/toolbar_gtk/view/top_bar.rs +++ b/src/toolbar_gtk/view/top_bar.rs @@ -222,6 +222,8 @@ struct SessionMenuContentKey { active_session_name: Option, active_session_path: Option, recent_sessions: Vec, + home_session_name: Option, + at_home_session: bool, pending_save_as_overwrite_path: Option, use_icons: bool, } @@ -233,6 +235,8 @@ impl SessionMenuContentKey { active_session_name: snapshot.active_session_name.clone(), active_session_path: snapshot.active_session_path.clone(), recent_sessions: snapshot.recent_sessions.clone(), + home_session_name: snapshot.home_session_name.clone(), + at_home_session: snapshot.at_home_session, pending_save_as_overwrite_path: snapshot.pending_save_as_overwrite_path.clone(), use_icons: snapshot.use_icons, } diff --git a/src/toolbar_gtk/view/top_bar/tests/pane_popovers.rs b/src/toolbar_gtk/view/top_bar/tests/pane_popovers.rs index 8081741e..f4424021 100644 --- a/src/toolbar_gtk/view/top_bar/tests/pane_popovers.rs +++ b/src/toolbar_gtk/view/top_bar/tests/pane_popovers.rs @@ -21,6 +21,8 @@ pub(super) fn assert_menu_popover_contracts(regular: &ToolbarSnapshot) { display_name: "recent-0.wayscriber-session".to_string(), path: std::path::PathBuf::from("/tmp/recent-0.wayscriber-session"), }]; + session_snapshot.home_session_name = Some("home.wayscriber-session".to_string()); + session_snapshot.at_home_session = false; let (tx, menu_rx) = std::sync::mpsc::channel(); let mut menu_top = TopBar::new_for_test(FeedbackSender::new(tx)); // Building the strip creates the two overflow-anchored native popovers. @@ -52,9 +54,10 @@ fn assert_session_popover_contract( find_widget_named(&content, "top.menu.session.panel").expect("session popover panel box"); let mut buttons: Vec = Vec::new(); collect_descendants(&panel, &mut buttons); + let home = model.home.as_ref().expect("home row"); assert_eq!( buttons.len(), - model.buttons.len() + model.recents.len(), + model.buttons.len() + 1 + model.recents.len(), "the popover exposes exactly the pane's controls" ); for (button, button_model) in buttons.iter().zip(model.buttons.iter()) { @@ -70,6 +73,21 @@ fn assert_session_popover_contract( rebind_requested: false, } ); + let home_button = &buttons[model.buttons.len()]; + assert_eq!( + home_button.tooltip_text().as_deref(), + Some(home.label.as_str()) + ); + assert_eq!(home_button.is_sensitive(), home.enabled); + home_button.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK home event"), + GtkToolbarFeedback::Event { + event: home.event(), + rebind_requested: false, + } + ); buttons.last().expect("recent row button").emit_clicked(); assert_eq!( rx.recv_timeout(Duration::from_secs(1)) diff --git a/src/ui/toolbar/events.rs b/src/ui/toolbar/events.rs index 73463ef9..f8748045 100644 --- a/src/ui/toolbar/events.rs +++ b/src/ui/toolbar/events.rs @@ -166,6 +166,9 @@ pub enum ToolbarEvent { ClearPreset(usize), OpenSession, OpenRecentSession(PathBuf), + /// Return to the home session: the daemon's startup session file, or the + /// configured default session. + OpenHomeSession, SaveSessionAs, SaveSessionAsConfirm(PathBuf), SaveSessionAsCancel, diff --git a/src/ui/toolbar/model/event_policy.rs b/src/ui/toolbar/model/event_policy.rs index 13c4f258..196fc30f 100644 --- a/src/ui/toolbar/model/event_policy.rs +++ b/src/ui/toolbar/model/event_policy.rs @@ -236,6 +236,7 @@ pub(crate) fn action_for_event(event: &ToolbarEvent) -> Option { // and Settings preferences: toolbar state rather than drawing actions. ToolbarEvent::OpenSession | ToolbarEvent::OpenRecentSession(_) + | ToolbarEvent::OpenHomeSession | ToolbarEvent::SaveSessionAs | ToolbarEvent::SaveSessionAsConfirm(_) | ToolbarEvent::SaveSessionAsCancel @@ -464,6 +465,7 @@ pub(crate) fn popovers_for_event(event: &ToolbarEvent) -> &'static [ToolbarPopov // Session hosts the session controls. ToolbarEvent::OpenSession | ToolbarEvent::OpenRecentSession(_) + | ToolbarEvent::OpenHomeSession | ToolbarEvent::SaveSessionAs | ToolbarEvent::SaveSessionAsConfirm(_) | ToolbarEvent::SaveSessionAsCancel @@ -747,6 +749,7 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { | ToolbarEvent::ClearPreset(_) | ToolbarEvent::OpenSession | ToolbarEvent::OpenRecentSession(_) + | ToolbarEvent::OpenHomeSession | ToolbarEvent::SaveSessionAs | ToolbarEvent::SaveSessionAsConfirm(_) | ToolbarEvent::SaveSessionAsCancel diff --git a/src/ui/toolbar/model/mod.rs b/src/ui/toolbar/model/mod.rs index d643860c..75aab0a5 100644 --- a/src/ui/toolbar/model/mod.rs +++ b/src/ui/toolbar/model/mod.rs @@ -684,6 +684,20 @@ mod tests { .any(|button| button.event == ToolbarEvent::SessionInfo), "hiding side.session.info removes the Session Info control" ); + assert!( + ToolbarSessionModel::for_popover(&snapshot) + .expect("session") + .home + .is_some() + ); + hide(&mut snapshot, ids::SIDE_SESSION_HOME); + assert!( + ToolbarSessionModel::for_popover(&snapshot) + .expect("session") + .home + .is_none(), + "hiding side.session.home removes the way back to the home session" + ); snapshot = self::snapshot(); let about_visible = |snapshot: &ToolbarSnapshot| { diff --git a/src/ui/toolbar/model/session.rs b/src/ui/toolbar/model/session.rs index 24631ebb..aaff8aab 100644 --- a/src/ui/toolbar/model/session.rs +++ b/src/ui/toolbar/model/session.rs @@ -12,6 +12,7 @@ pub(crate) struct ToolbarSessionModel { pub(crate) active_name: String, pub(crate) active_path_label: String, pub(crate) buttons: Vec, + pub(crate) home: Option, pub(crate) recents: Vec, pub(crate) overwrite_confirmation: Option, } @@ -44,6 +45,8 @@ impl ToolbarSessionModel { .into_iter() .filter(|button| session_button_visible(snapshot, &button.event)) .collect(); + let home = (!snapshot.toolbar_item_hidden(ids::SIDE_SESSION_HOME)) + .then(|| ToolbarSessionHome::from_snapshot(snapshot)); let recents = if target_active { snapshot .recent_sessions @@ -62,10 +65,11 @@ impl ToolbarSessionModel { path: path.clone(), }); - (!buttons.is_empty() || !recents.is_empty()).then_some(Self { + (!buttons.is_empty() || home.is_some() || !recents.is_empty()).then_some(Self { active_name, active_path_label, buttons, + home, recents, overwrite_confirmation, }) @@ -109,6 +113,32 @@ impl ToolbarSessionButton { } } +/// The way back to the home session: the daemon's startup session file, or +/// the configured default session. +#[derive(Debug, Clone)] +pub(crate) struct ToolbarSessionHome { + /// "Back to ", or "Default session". + pub(crate) label: String, + /// Off while home is already active: there is nothing to return to. + pub(crate) enabled: bool, +} + +impl ToolbarSessionHome { + fn from_snapshot(snapshot: &ToolbarSnapshot) -> Self { + Self { + label: snapshot.home_session_name.as_ref().map_or_else( + || "Default session".to_owned(), + |name| format!("Back to {name}"), + ), + enabled: !snapshot.at_home_session, + } + } + + pub(crate) fn event(&self) -> ToolbarEvent { + ToolbarEvent::OpenHomeSession + } +} + #[derive(Debug, Clone)] pub(crate) struct ToolbarSessionRecent { pub(crate) label: String, @@ -220,6 +250,32 @@ mod tests { assert_eq!(model.active_path_label, "No persisted session target"); } + #[test] + fn session_model_offers_the_way_home_only_away_from_home() { + let mut snapshot = app_snapshot(); + snapshot.active_session_path = Some(PathBuf::from("/tmp/b.wayscriber-session")); + + for (name, label) in [ + (None, "Default session"), + ( + Some("home.wayscriber-session"), + "Back to home.wayscriber-session", + ), + ] { + snapshot.home_session_name = name.map(str::to_owned); + for at_home in [false, true] { + snapshot.at_home_session = at_home; + + let model = ToolbarSessionModel::for_popover(&snapshot).expect("session model"); + let home = model.home.expect("home row"); + + assert_eq!(home.label, label); + assert_eq!(home.enabled, !at_home, "{label}, at home {at_home}"); + assert_eq!(home.event(), ToolbarEvent::OpenHomeSession); + } + } + } + #[test] fn session_model_exposes_pending_save_as_overwrite_confirmation() { let mut snapshot = app_snapshot(); diff --git a/src/ui/toolbar/snapshot/build.rs b/src/ui/toolbar/snapshot/build.rs index af3e88e1..0a06dfc1 100644 --- a/src/ui/toolbar/snapshot/build.rs +++ b/src/ui/toolbar/snapshot/build.rs @@ -241,6 +241,8 @@ impl ToolbarSnapshot { active_session_name: None, active_session_path: None, recent_sessions: Vec::new(), + home_session_name: None, + at_home_session: true, pending_save_as_overwrite_path: state.pending_save_as_overwrite().map(PathBuf::from), runtime_ui_persistence: None, } diff --git a/src/ui/toolbar/snapshot/types.rs b/src/ui/toolbar/snapshot/types.rs index 38d81aa8..2c91808d 100644 --- a/src/ui/toolbar/snapshot/types.rs +++ b/src/ui/toolbar/snapshot/types.rs @@ -465,6 +465,11 @@ pub struct ToolbarSnapshot { pub active_session_path: Option, /// Recent persisted sessions from the catalog. pub recent_sessions: Vec, + /// The named session the Session menu returns home to; `None` is the + /// configured default session. + pub home_session_name: Option, + /// Whether the active session is home, leaving nothing to return to. + pub at_home_session: bool, /// Save Session As target waiting for explicit overwrite confirmation. pub pending_save_as_overwrite_path: Option, /// Generated runtime UI preference persistence and recovery state. From c25605d2b85bd7fdc82b09b97de66fdabf001ea8 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:17:12 +0200 Subject: [PATCH 07/14] fix(session): settle a remembered session in the load that uses it The first load checked once, ahead of loading, whether any artifact of the remembered session existed, and then loaded it with startup rules. A file moved away came back from the backup left beside it; one replaced by a symlink or directory opened as an empty canvas whose saves then failed; and a file deleted after the check, or before a retry of a failed load, became a new empty session at the missing path. A remembered session now loads through its own worker operation, which holds it to the runtime Open checks before and after the load: the file itself must be a usable regular session file, not merely a backup or recovery copy. Every attempt until a session has loaded runs that operation, including retries. When the file cannot be used, home loads in its place in the same attempt; the notice and the report to the daemon follow once home has committed. A continued remembered session is reported as the overlay starts, before the daemon sees it ready. The daemon launched it at home, so until now its visible-target guard took the overlay for being at home. A report that could not be written is tried again on the next commit instead of being treated as delivered. Home's options in a daemon launch follow the resume policy the daemon passed. The run's own override is the one its --session-file forces on and says nothing about the default session, so a daemon started with --no-resume-session no longer gets a persistent default home. --- src/backend/wayland/backend/helpers.rs | 26 +- src/backend/wayland/backend/state_init/mod.rs | 9 +- .../wayland/backend/state_init/session.rs | 66 ++++- src/backend/wayland/session.rs | 4 +- src/backend/wayland/session/home.rs | 146 ++++++++-- src/backend/wayland/session/home/tests.rs | 261 +++++++++++++++--- src/backend/wayland/session/persistence.rs | 8 + .../wayland/session/persistence/worker.rs | 18 ++ .../wayland/state/core/output/session_ops.rs | 56 +++- .../wayland/state/core/output/transition.rs | 18 +- src/backend/wayland/state/core/session.rs | 2 +- .../wayland/state/core/session_home.rs | 98 +++---- .../wayland/state/toolbar/events/session.rs | 1 + .../state/toolbar/events/session/home.rs | 7 +- 14 files changed, 540 insertions(+), 180 deletions(-) diff --git a/src/backend/wayland/backend/helpers.rs b/src/backend/wayland/backend/helpers.rs index ca32d3bf..ace2d629 100644 --- a/src/backend/wayland/backend/helpers.rs +++ b/src/backend/wayland/backend/helpers.rs @@ -285,9 +285,12 @@ pub(super) fn dispatch_with_timeout( } pub(super) fn resume_override_from_env() -> Option { - if let Some(runtime) = runtime_session_override() { - return Some(runtime); - } + runtime_session_override().or_else(resume_override_from_env_var) +} + +/// The resume policy the launch environment passed, without the override +/// this run applies for itself, such as the one a named session file forces. +pub(super) fn resume_override_from_env_var() -> Option { match env::var(RESUME_SESSION_ENV) { Ok(raw) => { let normalized = raw.trim().to_ascii_lowercase(); @@ -309,21 +312,14 @@ pub(super) fn resume_override_from_env() -> Option { #[cfg(test)] mod tests { + use super::*; + use crate::capture::CaptureError; + use crate::set_runtime_session_override; use std::collections::VecDeque; use std::io::Write; use std::os::unix::net::UnixStream; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::{Mutex, OnceLock}; - - use super::*; - use crate::capture::CaptureError; - use crate::set_runtime_session_override; - - fn env_mutex() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } #[test] fn timeout_to_poll_ms_supports_none_and_caps_large_values() { @@ -807,7 +803,7 @@ mod tests { #[test] fn resume_override_from_env_prefers_runtime_override() { - let _guard = env_mutex().lock().unwrap(); + let _guard = crate::test_env::lock(); // SAFETY: test serialized by env mutex. unsafe { @@ -826,7 +822,7 @@ mod tests { #[test] fn resume_override_from_env_parses_expected_values() { - let _guard = env_mutex().lock().unwrap(); + let _guard = crate::test_env::lock(); set_runtime_session_override(None); // SAFETY: test serialized by env mutex. diff --git a/src/backend/wayland/backend/state_init/mod.rs b/src/backend/wayland/backend/state_init/mod.rs index 8bb992f6..a8f3c62a 100644 --- a/src/backend/wayland/backend/state_init/mod.rs +++ b/src/backend/wayland/backend/state_init/mod.rs @@ -48,11 +48,7 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul .as_ref() .is_some_and(|failure| failure.section_failed("session")); let launch = SessionLaunch::from_environment(backend.named_session_file.as_deref()); - let home_options = session::session_options_for( - &config, - &config_dir, - launch.home.file().map(Path::to_path_buf), - ); + let home_options = session::home_session_options(&config, &config_dir, &launch); if let Some(preferred) = &launch.preferred { info!("Continuing remembered session {}", preferred.display()); } @@ -236,6 +232,9 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul tablet_manager, }); + // A continued remembered session is reported before the daemon sees this + // overlay ready, so it never mistakes the overlay for being at home. + state.report_session_to_daemon(); // Decide the toolbar frontend before the first visibility sync so the // built-in surfaces are never created just to be torn down when the // GTK bars take over. diff --git a/src/backend/wayland/backend/state_init/session.rs b/src/backend/wayland/backend/state_init/session.rs index df919b1d..3b885a2f 100644 --- a/src/backend/wayland/backend/state_init/session.rs +++ b/src/backend/wayland/backend/state_init/session.rs @@ -2,11 +2,12 @@ use log::{info, warn}; use std::env; use std::path::{Path, PathBuf}; +use crate::backend::wayland::session::SessionLaunch; use crate::config::Config; use crate::env_vars::WAYLAND_DISPLAY_ENV; use crate::{RESUME_SESSION_ENV, paths, session}; -use super::super::helpers::resume_override_from_env; +use super::super::helpers::{resume_override_from_env, resume_override_from_env_var}; /// The options this run starts with, logged. pub(super) fn build_session_options( @@ -14,20 +15,47 @@ pub(super) fn build_session_options( config_dir: &Path, named_session_file: Option, ) -> Option { - let session_options = session_options_for(config, config_dir, named_session_file); + let session_options = session_options_for( + config, + config_dir, + named_session_file, + resume_override_from_env(), + ); log_session_options(session_options.as_ref()); session_options } +/// The options of the session an overlay returns home to. A daemon passes its +/// resume policy for home; this run's own override may instead be the one its +/// `--session-file` forces on, which says nothing about the default session. +pub(super) fn home_session_options( + config: &Config, + config_dir: &Path, + launch: &SessionLaunch, +) -> Option { + let resume_override = if launch.from_daemon { + resume_override_from_env_var() + } else { + resume_override_from_env() + }; + session_options_for( + config, + config_dir, + launch.home.file().map(Path::to_path_buf), + resume_override, + ) +} + /// The options for a run in `named_session_file`, or in the configured -/// default session without one; `None` when that has persistence disabled. -pub(super) fn session_options_for( +/// default session without one, under `resume_override`; `None` when that has +/// persistence disabled. +fn session_options_for( config: &Config, config_dir: &Path, named_session_file: Option, + resume_override: Option, ) -> Option { let display_env = env::var(WAYLAND_DISPLAY_ENV).ok(); - let resume_override = resume_override_from_env(); let mut session_options = if let Some(path) = named_session_file { let mut options = session::options_from_config_for_named_file( &config.session, @@ -145,4 +173,32 @@ mod tests { assert!(options.persist_history); assert!(options.restore_tool_state); } + + #[test] + fn a_daemon_home_follows_the_daemon_resume_policy_not_the_runs_own() { + let off = std::ffi::OsStr::new("off"); + crate::test_env::with_env_var(RESUME_SESSION_ENV, Some(off), || { + let previous = crate::runtime_session_override(); + // What a run whose --session-file is not home sets for itself. + crate::set_runtime_session_override(Some(true)); + let home = |from_daemon| { + home_session_options( + &Config::default(), + Path::new("/tmp/config"), + &SessionLaunch { + home: crate::backend::wayland::session::HomeSession::Default, + preferred: None, + from_daemon, + }, + ) + }; + + let daemon_home = home(true); + let standalone_home = home(false); + crate::set_runtime_session_override(previous); + + assert!(daemon_home.is_none(), "the daemon passed resume off"); + assert!(standalone_home.is_some_and(|options| options.persist_history)); + }); + } } diff --git a/src/backend/wayland/session.rs b/src/backend/wayland/session.rs index b1aa6571..4caa6293 100644 --- a/src/backend/wayland/session.rs +++ b/src/backend/wayland/session.rs @@ -607,7 +607,9 @@ mod runtime; #[cfg(test)] pub(in crate::backend::wayland) use home::HomeSession; -pub(in crate::backend::wayland) use home::{SessionHome, SessionLaunch, session_target}; +pub(in crate::backend::wayland) use home::{ + SessionHome, SessionLaunch, load_output_session, session_target, +}; pub(in crate::backend::wayland) use persistence::{ PersistenceCompletion, PersistenceController, PersistenceOperation, PersistenceOutcome, RequestId, SaveCompletion, SaveStrategy, SubmitFailure, diff --git a/src/backend/wayland/session/home.rs b/src/backend/wayland/session/home.rs index 02e4e2a0..bad4a05b 100644 --- a/src/backend/wayland/session/home.rs +++ b/src/backend/wayland/session/home.rs @@ -9,12 +9,15 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; +use anyhow::{Result, bail}; + +use super::{PersistenceOperation, PersistenceOutcome}; use crate::daemon::protocol_v2::ReportedSession; use crate::env_vars::{ OVERLAY_HOME_SESSION_ENV, OVERLAY_PREFERRED_SESSION_ENV, OVERLAY_SESSION_REPORTS_ENV, }; use crate::session::catalog::session_paths_match; -use crate::session::{SessionOptions, SessionTarget}; +use crate::session::{LoadSnapshotOutcome, SessionOptions, SessionTarget}; use crate::ui::toolbar::session_format::session_display_name; /// The session an overlay returns home to. @@ -41,6 +44,9 @@ pub(in crate::backend::wayland) struct SessionLaunch { pub(in crate::backend::wayland) home: HomeSession, /// A remembered session to start in instead of home, if it still exists. pub(in crate::backend::wayland) preferred: Option, + /// Whether a daemon that reads reports launched the overlay, so its + /// resume policy is the one the daemon passed. + pub(in crate::backend::wayland) from_daemon: bool, } impl SessionLaunch { @@ -64,6 +70,7 @@ impl SessionLaunch { HomeSession::Named(path.to_path_buf()) }), preferred: None, + from_daemon: false, }; } @@ -74,7 +81,11 @@ impl SessionLaunch { let preferred = present(OVERLAY_PREFERRED_SESSION_ENV).filter(|_| startup_file == home.file()); - Self { home, preferred } + Self { + home, + preferred, + from_daemon: true, + } } } @@ -85,14 +96,17 @@ pub(in crate::backend::wayland) struct SessionHome { /// Home's session options before an output identity is applied; `None` /// when home has persistence disabled. options: Option, - /// The preferred session, until the first load checks that it still exists. - unchecked_preferred: Option, - /// The target the daemon last learned this overlay is in, by launching it - /// there or from a report. - reported: SessionTarget, - /// Whether `reported` is home. Kept rather than worked out when asked, + /// The remembered session this run was asked to continue. + remembered: Option, + /// The target the overlay is in, as of the last commit. + current: SessionTarget, + /// Whether `current` is home. Kept rather than worked out when asked, /// since the Session menu asks on every redraw. at_home: bool, + /// The target the daemon last learned this overlay is in, by launching it + /// there or from a report. `None` until a continued remembered session is + /// reported: the daemon launched the overlay at home, not in it. + reported: Option, } impl SessionHome { @@ -104,21 +118,27 @@ impl SessionHome { ) -> Self { Self { at_home: is_home(&launch.home, &startup), + reported: launch.preferred.is_none().then(|| startup.clone()), home: launch.home, options, - unchecked_preferred: launch.preferred, - reported: startup, + remembered: launch.preferred, + current: startup, } } - pub(in crate::backend::wayland) fn options(&self) -> Option<&SessionOptions> { - self.options.as_ref() + /// Home's options for the output identified as `output_identity`. + pub(in crate::backend::wayland) fn options_for_output( + &self, + output_identity: Option<&str>, + ) -> Option { + let mut options = self.options.clone()?; + options.set_output_identity(output_identity); + Some(options) } - /// The preferred session this run started in, once: only the first load - /// checks it. - pub(in crate::backend::wayland) fn take_unchecked_preferred(&mut self) -> Option { - self.unchecked_preferred.take() + /// The remembered session this run was asked to continue. + pub(in crate::backend::wayland) fn remembered(&self) -> Option<&Path> { + self.remembered.as_deref() } /// The name of a named home session; `None` for the default session. @@ -137,24 +157,32 @@ impl SessionHome { self.at_home } - /// Records that the overlay is now in `target`, returning what to report, - /// or `None` when the daemon already knows. Home is reported as such even - /// when it is a named file, so the daemon never remembers home as a - /// session of its own. - pub(in crate::backend::wayland) fn commit_target( - &mut self, - target: SessionTarget, - ) -> Option { - if target == self.reported { + /// Records that the overlay is now in `target`. + pub(in crate::backend::wayland) fn enter(&mut self, target: SessionTarget) { + if target != self.current { + self.at_home = is_home(&self.home, &target); + self.current = target; + } + } + + /// What the daemon has yet to learn about the overlay's session. Home is + /// reported as such even when it is a named file, so the daemon never + /// remembers home as a session of its own. + pub(in crate::backend::wayland) fn unreported(&self) -> Option { + if self.reported.as_ref() == Some(&self.current) { return None; } - self.at_home = is_home(&self.home, &target); - let report = match &target { + + Some(match &self.current { SessionTarget::NamedFile(path) if !self.at_home => ReportedSession::Named(path.clone()), _ => ReportedSession::Home, - }; - self.reported = target; - Some(report) + }) + } + + /// The daemon now knows the overlay's session; a failed report is tried + /// again on the next commit instead. + pub(in crate::backend::wayland) fn mark_reported(&mut self) { + self.reported = Some(self.current.clone()); } } @@ -176,5 +204,63 @@ pub(in crate::backend::wayland) fn session_target( options.map_or(SessionTarget::Configured, |options| options.target.clone()) } +/// What an output's session load found. +#[derive(Debug)] +pub(in crate::backend::wayland) struct OutputSessionLoad { + /// The options loaded and what loading them found: the staged options, or + /// home's in place of a remembered session that can no longer be used. + /// `None` when that home has persistence disabled, leaving nothing to load. + pub(in crate::backend::wayland) loaded: Option<(SessionOptions, LoadSnapshotOutcome)>, + /// The remembered session given up for home, and why. + pub(in crate::backend::wayland) abandoned: Option<(PathBuf, anyhow::Error)>, +} + +/// Loads `staged`, the session an output starts in, through `run`. When it is +/// the `remembered` session the run was asked to continue, its file must be +/// usable as the load runs, every attempt: otherwise `home` loads instead. +pub(in crate::backend::wayland) fn load_output_session( + staged: SessionOptions, + remembered: Option<&Path>, + home: Option, + mut run: impl FnMut(PersistenceOperation) -> Result, +) -> Result { + let continues_remembered = + remembered.is_some_and(|path| staged.target == SessionTarget::NamedFile(path.into())); + let operation = if continues_remembered { + PersistenceOperation::LoadRemembered { + options: staged.clone(), + } + } else { + PersistenceOperation::LoadConfigured { + options: staged.clone(), + } + }; + let abandoned = match run(operation)? { + PersistenceOutcome::Load(outcome) => { + return Ok(OutputSessionLoad { + loaded: Some((staged, outcome)), + abandoned: None, + }); + } + PersistenceOutcome::RememberedUnavailable(error) => (staged.session_file_path(), error), + other => bail!("unexpected session load outcome: {other:?}"), + }; + + let loaded = match home { + Some(home) => match run(PersistenceOperation::LoadConfigured { + options: home.clone(), + })? { + PersistenceOutcome::Load(outcome) => Some((home, outcome)), + other => bail!("unexpected home session load outcome: {other:?}"), + }, + None => None, + }; + + Ok(OutputSessionLoad { + loaded, + abandoned: Some(abandoned), + }) +} + #[cfg(test)] mod tests; diff --git a/src/backend/wayland/session/home/tests.rs b/src/backend/wayland/session/home/tests.rs index afb6ad8d..9f0f4a75 100644 --- a/src/backend/wayland/session/home/tests.rs +++ b/src/backend/wayland/session/home/tests.rs @@ -29,6 +29,7 @@ fn a_daemon_launch_carries_home_and_the_remembered_session() { SessionLaunch { home: named(HOME), preferred: Some(PathBuf::from(PREFERRED)), + from_daemon: true, } ); } @@ -45,6 +46,7 @@ fn without_a_startup_file_home_is_the_default_session() { SessionLaunch { home: HomeSession::Default, preferred: Some(PathBuf::from(PREFERRED)), + from_daemon: true, } ); } @@ -82,6 +84,7 @@ fn without_a_daemon_that_reads_reports_home_is_the_startup_session() { SessionLaunch { home: named(startup), preferred: None, + from_daemon: false, } ); assert_eq!( @@ -89,6 +92,7 @@ fn without_a_daemon_that_reads_reports_home_is_the_startup_session() { SessionLaunch { home: HomeSession::Default, preferred: None, + from_daemon: false, } ); } @@ -99,6 +103,7 @@ fn home_session(home: HomeSession, preferred: Option<&str>, startup: SessionTarg SessionLaunch { home, preferred: preferred.map(PathBuf::from), + from_daemon: true, }, None, startup, @@ -109,38 +114,72 @@ fn file(path: &str) -> SessionTarget { SessionTarget::NamedFile(PathBuf::from(path)) } +/// Enters `target` and reports it, returning what was reported. +fn report(home: &mut SessionHome, target: SessionTarget) -> Option { + home.enter(target); + let report = home.unreported(); + home.mark_reported(); + report +} + #[test] fn home_is_named_like_any_other_session() { let named = home_session(named(HOME), None, file(HOME)); let default = home_session(HomeSession::Default, None, SessionTarget::Configured); assert_eq!(named.label(), "home.wayscriber-session"); + assert_eq!(named.name().as_deref(), Some("home.wayscriber-session")); assert_eq!(default.label(), "the default session"); + assert_eq!(default.name(), None); } #[test] -fn only_the_first_load_checks_the_preferred_session() { - let mut home = home_session(HomeSession::Default, Some(PREFERRED), file(PREFERRED)); - +fn a_continued_remembered_session_is_reported_before_anything_changes() { + // The daemon launched the overlay at home; only the overlay knows that it + // continues the remembered session instead. + let continued = home_session(named(HOME), Some(PREFERRED), file(PREFERRED)); assert_eq!( - home.take_unchecked_preferred(), - Some(PathBuf::from(PREFERRED)) + continued.unreported(), + Some(ReportedSession::Named(PathBuf::from(PREFERRED))) ); - assert_eq!(home.take_unchecked_preferred(), None); + + for started_at_launch_target in [ + home_session(named(HOME), None, file(HOME)), + home_session(named(HOME), None, file("/sessions/c.wayscriber-session")), + home_session(HomeSession::Default, None, SessionTarget::Configured), + ] { + assert_eq!(started_at_launch_target.unreported(), None); + } } #[test] fn only_a_changed_session_is_reported() { - let mut home = home_session(named(HOME), Some(PREFERRED), file(PREFERRED)); + let mut home = home_session(named(HOME), None, file(HOME)); - assert_eq!(home.commit_target(file(PREFERRED)), None); + assert_eq!(report(&mut home, file(HOME)), None); + + let other = "/sessions/d.wayscriber-session"; + assert_eq!( + report(&mut home, file(other)), + Some(ReportedSession::Named(PathBuf::from(other))) + ); + assert_eq!(report(&mut home, file(other)), None); +} +#[test] +fn a_report_that_was_not_written_is_tried_again() { + let mut home = home_session(named(HOME), None, file(HOME)); let other = "/sessions/d.wayscriber-session"; + + home.enter(file(other)); + assert!(home.unreported().is_some()); + // The write failed, so nothing was marked reported. + home.enter(file(other)); + assert_eq!( - home.commit_target(file(other)), + home.unreported(), Some(ReportedSession::Named(PathBuf::from(other))) ); - assert_eq!(home.commit_target(file(other)), None); } #[test] @@ -150,53 +189,211 @@ fn home_is_reported_as_home_even_as_a_named_file() { let alias = "/sessions/./home.wayscriber-session"; assert_eq!( - named_home.commit_target(file(alias)), + report(&mut named_home, file(alias)), Some(ReportedSession::Home) ); let mut default_home = home_session(HomeSession::Default, None, file(PREFERRED)); assert_eq!( - default_home.commit_target(SessionTarget::Configured), + report(&mut default_home, SessionTarget::Configured), Some(ReportedSession::Home) ); assert_eq!( - default_home.commit_target(file(HOME)), + report(&mut default_home, file(HOME)), Some(ReportedSession::Named(PathBuf::from(HOME))) ); } -#[test] -fn a_run_without_persistence_is_in_the_default_session() { - assert_eq!(session_target(None), SessionTarget::Configured); -} - #[test] fn the_overlay_knows_whether_it_is_home() { let mut named_home = home_session(named(HOME), Some(PREFERRED), file(PREFERRED)); assert!(!named_home.is_at_home()); - named_home.commit_target(file("/sessions/./home.wayscriber-session")); + named_home.enter(file("/sessions/./home.wayscriber-session")); assert!(named_home.is_at_home()); - named_home.commit_target(file(PREFERRED)); + named_home.enter(file(PREFERRED)); assert!(!named_home.is_at_home()); assert!(home_session(named(HOME), None, file(HOME)).is_at_home()); let mut default_home = home_session(HomeSession::Default, None, file(PREFERRED)); assert!(!default_home.is_at_home()); - default_home.commit_target(SessionTarget::Configured); + default_home.enter(SessionTarget::Configured); assert!(default_home.is_at_home()); assert!(home_session(HomeSession::Default, None, SessionTarget::Configured).is_at_home()); } #[test] -fn only_a_named_home_has_a_name() { - assert_eq!( - home_session(named(HOME), None, file(HOME)) - .name() - .as_deref(), - Some("home.wayscriber-session") - ); - assert_eq!( - home_session(HomeSession::Default, None, SessionTarget::Configured).name(), - None - ); +fn a_run_without_persistence_is_in_the_default_session() { + assert_eq!(session_target(None), SessionTarget::Configured); +} + +mod load { + use super::super::super::tests::{EnvGuard, named_options, sample_snapshot}; + use super::*; + use crate::backend::wayland::session::PersistenceController; + use crate::session as stored_session; + + struct Sessions { + temp: crate::test_temp::TempDir, + remembered: SessionOptions, + home: SessionOptions, + persistence: PersistenceController, + } + + /// A saved remembered session and a saved configured home. + fn sessions() -> Sessions { + let temp = crate::test_temp::tempdir().unwrap(); + let remembered = named_options(temp.path(), "remembered"); + let mut home = SessionOptions::new(temp.path().join("configured"), "home"); + home.persist_transparent = true; + stored_session::save_snapshot(&sample_snapshot(), &remembered).unwrap(); + stored_session::save_snapshot(&sample_snapshot(), &home).unwrap(); + + Sessions { + temp, + remembered, + home, + persistence: PersistenceController::start_for_test().unwrap(), + } + } + + impl Sessions { + fn load(&mut self, home: Option) -> Result { + let path = self.remembered.session_file_path(); + load_output_session(self.remembered.clone(), Some(&path), home, |operation| { + self.persistence.run(0, operation) + }) + } + + fn assert_went_home(&mut self, load: OutputSessionLoad) -> anyhow::Error { + let (options, outcome) = load.loaded.expect("home loads"); + assert_eq!(options.target, self.home.target); + assert!(matches!(outcome, LoadSnapshotOutcome::Loaded(_))); + let (path, error) = load.abandoned.expect("the remembered session is given up"); + assert_eq!(path, self.remembered.session_file_path()); + error + } + } + + #[test] + fn a_remembered_session_that_is_still_there_is_continued() { + let mut sessions = sessions(); + let home = sessions.home.clone(); + + let load = sessions.load(Some(home)).unwrap(); + + let (options, outcome) = load.loaded.unwrap(); + assert_eq!(options.target, sessions.remembered.target); + assert!(matches!(outcome, LoadSnapshotOutcome::Loaded(_))); + assert!(load.abandoned.is_none()); + } + + #[test] + fn a_deleted_remembered_session_starts_at_home() { + let mut sessions = sessions(); + std::fs::remove_file(sessions.remembered.session_file_path()).unwrap(); + let home = sessions.home.clone(); + + let load = sessions.load(Some(home)).unwrap(); + + let error = sessions.assert_went_home(load); + assert!( + error + .downcast_ref::() + .is_some(), + "{error:#}" + ); + } + + #[test] + fn a_moved_remembered_session_is_not_continued_from_its_backup() { + let mut sessions = sessions(); + let path = sessions.remembered.session_file_path(); + // A second save leaves a backup of the first beside the file. + stored_session::save_snapshot(&sample_snapshot(), &sessions.remembered).unwrap(); + assert!(sessions.remembered.backup_file_path().exists()); + std::fs::rename(&path, path.with_extension("moved")).unwrap(); + let home = sessions.home.clone(); + let _env = EnvGuard::set_xdg_data_home(sessions.temp.path()); + + let load = sessions.load(Some(home)).unwrap(); + + sessions.assert_went_home(load); + // Its backup was never loaded, so nothing records it as opened. + assert!( + stored_session::catalog::recent_sessions() + .unwrap() + .is_empty() + ); + } + + #[test] + fn a_remembered_session_that_became_a_symlink_or_directory_is_not_used() { + for replace in ["symlink", "directory"] { + let mut sessions = sessions(); + let path = sessions.remembered.session_file_path(); + let moved = path.with_extension("moved"); + std::fs::rename(&path, &moved).unwrap(); + if replace == "symlink" { + std::os::unix::fs::symlink(&moved, &path).unwrap(); + } else { + std::fs::create_dir(&path).unwrap(); + } + let home = sessions.home.clone(); + + let load = sessions.load(Some(home)).unwrap(); + + let error = sessions.assert_went_home(load); + assert!(format!("{error:#}").contains(replace), "{error:#}"); + } + } + + #[test] + fn a_session_deleted_before_a_retry_is_checked_again() { + let mut sessions = sessions(); + let path = sessions.remembered.session_file_path(); + let home = sessions.home.clone(); + // The first attempt fails as an I/O error would, before the file goes. + let failed = load_output_session( + sessions.remembered.clone(), + Some(&path), + Some(home.clone()), + |_| Err(anyhow::anyhow!("session load failed")), + ); + assert!(failed.is_err()); + std::fs::remove_file(&path).unwrap(); + + let load = sessions.load(Some(home)).unwrap(); + + sessions.assert_went_home(load); + } + + #[test] + fn without_persistence_home_has_nothing_to_load() { + let mut sessions = sessions(); + std::fs::remove_file(sessions.remembered.session_file_path()).unwrap(); + + let load = sessions.load(None).unwrap(); + + assert!(load.loaded.is_none()); + assert!(load.abandoned.is_some()); + } + + #[test] + fn a_session_file_given_at_launch_keeps_its_startup_rules() { + let mut sessions = sessions(); + std::fs::remove_file(sessions.remembered.session_file_path()).unwrap(); + let home = sessions.home.clone(); + let remembered = sessions.remembered.clone(); + + // Not the remembered session: a missing file is a new, empty session. + let load = load_output_session(remembered.clone(), None, Some(home), |operation| { + sessions.persistence.run(0, operation) + }) + .unwrap(); + + let (options, outcome) = load.loaded.unwrap(); + assert_eq!(options.target, remembered.target); + assert!(matches!(outcome, LoadSnapshotOutcome::Empty)); + assert!(load.abandoned.is_none()); + } } diff --git a/src/backend/wayland/session/persistence.rs b/src/backend/wayland/session/persistence.rs index ee3d1c06..37b847fe 100644 --- a/src/backend/wayland/session/persistence.rs +++ b/src/backend/wayland/session/persistence.rs @@ -52,6 +52,11 @@ pub(in crate::backend::wayland) enum PersistenceOperation { LoadNamedCandidate { options: SessionOptions, }, + /// Loads a remembered session file only while it is still a usable + /// session file, before and after it loads. + LoadRemembered { + options: SessionOptions, + }, Inspect { options: SessionOptions, }, @@ -93,6 +98,7 @@ impl PersistenceOperation { Self::SaveAs { .. } => "save-as", Self::LoadConfigured { .. } => "load-configured", Self::LoadNamedCandidate { .. } => "load-named-candidate", + Self::LoadRemembered { .. } => "load-remembered", Self::Inspect { .. } => "inspect", Self::SaveAsOverwritePreflight { .. } => "save-as-overwrite-preflight", Self::ValidateNamedOpen { .. } => "validate-named-open", @@ -127,6 +133,8 @@ pub(in crate::backend::wayland) enum PersistenceOutcome { committed_board_data: bool, }, Load(LoadSnapshotOutcome), + /// The remembered session file is gone or can no longer be used. + RememberedUnavailable(anyhow::Error), Inspection(SessionInspection), SaveAsPreflight { same_target: bool, diff --git a/src/backend/wayland/session/persistence/worker.rs b/src/backend/wayland/session/persistence/worker.rs index 3726285f..832cf072 100644 --- a/src/backend/wayland/session/persistence/worker.rs +++ b/src/backend/wayland/session/persistence/worker.rs @@ -134,6 +134,7 @@ pub(super) fn execute(operation: PersistenceOperation) -> Result Ok(PersistenceOutcome::Load( session::load_named_session_candidate(&options)?, )), + PersistenceOperation::LoadRemembered { options } => load_remembered(&options), PersistenceOperation::Inspect { options } => Ok(PersistenceOutcome::Inspection( session::inspect_session(&options)?, )), @@ -179,6 +180,23 @@ pub(super) fn execute(operation: PersistenceOperation) -> Result Result { + let path = options.session_file_path(); + if let Err(error) = session::validate_named_session_file_for_open(&path) { + return Ok(PersistenceOutcome::RememberedUnavailable(error)); + } + let outcome = session::load_snapshot_with_outcome(options)?; + + Ok(match session::validate_named_session_file_for_open(&path) { + Ok(()) => PersistenceOutcome::Load(outcome), + Err(error) => PersistenceOutcome::RememberedUnavailable(error), + }) +} + pub(super) fn save_as_preflight_after_validation( current_path: &Path, target_path: &Path, diff --git a/src/backend/wayland/state/core/output/session_ops.rs b/src/backend/wayland/state/core/output/session_ops.rs index ce3c3091..997d26df 100644 --- a/src/backend/wayland/state/core/output/session_ops.rs +++ b/src/backend/wayland/state/core/output/session_ops.rs @@ -1,24 +1,52 @@ use super::*; +use crate::backend::wayland::session::load_output_session; impl WaylandState { + /// Loads and commits `staged` as the session for the output identified as + /// `physical_output_identity`. Until a session has loaded, a remembered + /// session this run continues must still be usable; otherwise the overlay + /// starts at home instead, says so, and the daemon hears that it is home, + /// so the next show does not try that session again. pub(in crate::backend::wayland) fn load_configured_session_for_options( &mut self, - options: session::SessionOptions, + staged: session::SessionOptions, + physical_output_identity: Option<&str>, context: &str, ) -> anyhow::Result<()> { - let outcome = session_save::run_persistence_operation( - self, - PersistenceOperation::LoadConfigured { - options: options.clone(), - }, - )?; - let PersistenceOutcome::Load(load_outcome) = outcome else { - return Err(anyhow::anyhow!("unexpected configured-load worker outcome")); - }; - let loaded_board_data = load_outcome.has_board_data(); - self.handle_session_load_outcome_for_options(load_outcome, &options, context)?; - self.session - .commit_output_options(options, loaded_board_data); + let remembered = self + .session_home + .remembered() + .filter(|_| !self.session.is_loaded()) + .map(std::path::Path::to_path_buf); + let home = self + .session_home + .options_for_output(physical_output_identity); + + let load = load_output_session(staged, remembered.as_deref(), home, |operation| { + session_save::run_persistence_operation(self, operation) + })?; + + match load.loaded { + Some((options, outcome)) => { + let loaded_board_data = outcome.has_board_data(); + self.handle_session_load_outcome_for_options(outcome, &options, context)?; + if load.abandoned.is_some() { + self.input_state + .set_session_preflight_options(Some(options.clone())); + } + self.session + .commit_output_options(options, loaded_board_data); + } + None => { + self.session.replace_options_before_load(None); + self.input_state.set_session_preflight_options(None); + } + } + if let Some((path, error)) = load.abandoned { + self.notify_remembered_session_abandoned(&path, &error); + } + + self.report_session_to_daemon(); Ok(()) } diff --git a/src/backend/wayland/state/core/output/transition.rs b/src/backend/wayland/state/core/output/transition.rs index 3f13b116..f317f28b 100644 --- a/src/backend/wayland/state/core/output/transition.rs +++ b/src/backend/wayland/state/core/output/transition.rs @@ -6,7 +6,6 @@ impl WaylandState { physical_output_identity: Option, reason: &str, ) { - self.start_at_home_if_preferred_session_is_gone(); let Some(mut staged_options) = self.session_options().cloned() else { return; }; @@ -67,6 +66,7 @@ impl WaylandState { OutputTransitionStart::LoadInitial => { match self.load_configured_session_for_options( staged_options.clone(), + physical_output_identity.as_deref(), "initial output load", ) { // A load that already knows its output is the output's @@ -228,19 +228,11 @@ impl WaylandState { .ok_or_else(|| anyhow::anyhow!("output transition has no active session options"))?; self.persist_current_session_for_transition(¤t_options, reason)?; - let outcome = session_save::run_persistence_operation( - self, - PersistenceOperation::LoadConfigured { - options: staged_options.clone(), - }, + self.load_configured_session_for_options( + staged_options, + physical_output_identity.as_deref(), + "output load", )?; - let PersistenceOutcome::Load(load_outcome) = outcome else { - return Err(anyhow::anyhow!("unexpected output-load worker outcome")); - }; - let loaded_board_data = load_outcome.has_board_data(); - self.handle_session_load_outcome_for_options(load_outcome, &staged_options, "output load")?; - self.session - .commit_output_options(staged_options, loaded_board_data); info!( "Committed logical session output transition after {} (physical_output_identity={:?}, epoch={})", reason, diff --git a/src/backend/wayland/state/core/session.rs b/src/backend/wayland/state/core/session.rs index 6b35765e..a1d0a025 100644 --- a/src/backend/wayland/state/core/session.rs +++ b/src/backend/wayland/state/core/session.rs @@ -38,7 +38,7 @@ impl SessionCommandRuntime for WaylandState { } fn session_target_committed(&mut self) { - WaylandState::session_target_committed(self); + self.report_session_to_daemon(); } fn finish_session_command(&mut self, report: SessionCommandReport) { diff --git a/src/backend/wayland/state/core/session_home.rs b/src/backend/wayland/state/core/session_home.rs index fc1f960e..eda62eeb 100644 --- a/src/backend/wayland/state/core/session_home.rs +++ b/src/backend/wayland/state/core/session_home.rs @@ -1,82 +1,60 @@ +use std::path::Path; + use log::{info, warn}; use super::super::*; -use crate::backend::wayland::backend::event_loop::session_save; -use crate::backend::wayland::session::{PersistenceOperation, PersistenceOutcome, session_target}; +use crate::backend::wayland::session::session_target; use crate::daemon::protocol_v2::publish_session_from_environment; use crate::input::state::{Toast, ToastPriority}; +use crate::session::MissingNamedSessionFile; use crate::ui::toolbar::session_format::session_display_name; impl WaylandState { - /// Starts at home instead of the remembered session this run was asked to - /// continue when that session no longer exists: it was moved or deleted - /// after the daemon chose it. The daemon hears that this overlay is home, - /// so the next show does not try the missing session again. - pub(in crate::backend::wayland) fn start_at_home_if_preferred_session_is_gone(&mut self) { - let Some(preferred) = self.session_home.take_unchecked_preferred() else { - return; - }; - let Some(options) = self.session_options().cloned() else { - return; - }; - match session_save::run_persistence_operation( - self, - PersistenceOperation::HasArtifacts { options }, - ) { - Ok(PersistenceOutcome::HasArtifacts(true)) => return, - Ok(PersistenceOutcome::HasArtifacts(false)) => {} - Ok(other) => { - warn!("Unexpected outcome checking the remembered session: {other:?}"); - return; - } - // The load that follows reports the failure the way it would for - // a session file given at startup. - Err(error) => { - warn!( - "Failed to check remembered session {}: {error:#}", - preferred.display() - ); - return; - } - } - + pub(in crate::backend::wayland::state) fn notify_remembered_session_abandoned( + &mut self, + path: &Path, + error: &anyhow::Error, + ) { info!( - "Remembered session {} no longer exists; starting at home", - preferred.display() + "Remembered session {} cannot be continued; starting at home: {error:#}", + path.display() ); - let home = self.session_home.options().cloned(); - self.session.replace_options_before_load(home.clone()); - self.input_state.set_session_preflight_options(home); + let name = session_display_name(path); + let message = if error.downcast_ref::().is_some() { + format!("Session {name} is no longer available") + } else { + format!("Session {name} can no longer be used ({error:#})") + }; self.input_state.push_toast( ToastPriority::Info, "session", - Toast::warning(format!( - "Session {} is no longer available; opened {}", - session_display_name(&preferred), - self.session_home.label() - )), + Toast::warning(format!("{message}; opened {}", self.session_home.label())), ); - self.session_target_committed(); } /// Tells the daemon that launched this overlay the session it is now in, - /// if that changed, so the next show starts there. A failure leaves the - /// daemon with what it knew before, and only that is reported: the session - /// switch stands. - pub(in crate::backend::wayland) fn session_target_committed(&mut self) { - let target = session_target(self.session.options()); - let Some(session) = self.session_home.commit_target(target) else { + /// if the daemon does not know it yet, so the next show starts there. A + /// failure leaves the daemon with what it knew before, and only that is + /// reported: the session switch stands, and the next commit tries again. + pub(in crate::backend::wayland) fn report_session_to_daemon(&mut self) { + self.session_home + .enter(session_target(self.session.options())); + let Some(session) = self.session_home.unreported() else { return; }; - if let Err(error) = publish_session_from_environment(&session) { - warn!("Failed to report the session to the daemon: {error:#}"); - self.input_state.push_toast( - ToastPriority::Info, - "session.report", - Toast::warning(format!( - "The overlay may not reopen in this session after it hides: {error:#}" - )), - ); + + match publish_session_from_environment(&session) { + Ok(_) => self.session_home.mark_reported(), + Err(error) => { + warn!("Failed to report the session to the daemon: {error:#}"); + self.input_state.push_toast( + ToastPriority::Info, + "session.report", + Toast::warning(format!( + "The overlay may not reopen in this session after it hides: {error:#}" + )), + ); + } } } } diff --git a/src/backend/wayland/state/toolbar/events/session.rs b/src/backend/wayland/state/toolbar/events/session.rs index c56f20cf..e6e1f813 100644 --- a/src/backend/wayland/state/toolbar/events/session.rs +++ b/src/backend/wayland/state/toolbar/events/session.rs @@ -637,6 +637,7 @@ mod tests { SessionLaunch { home: crate::backend::wayland::session::HomeSession::Named(home), preferred: None, + from_daemon: true, }, None, SessionTarget::NamedFile("/sessions/b.wayscriber-session".into()), diff --git a/src/backend/wayland/state/toolbar/events/session/home.rs b/src/backend/wayland/state/toolbar/events/session/home.rs index c08cc98e..b8e3704d 100644 --- a/src/backend/wayland/state/toolbar/events/session/home.rs +++ b/src/backend/wayland/state/toolbar/events/session/home.rs @@ -16,14 +16,13 @@ impl WaylandState { /// Home's options for the output the overlay is on now, not those of the /// named session it is leaving. fn home_session_options(&self) -> Option { - let mut options = self.session_home.options()?.clone(); let output_identity = self .surface .current_output() .as_ref() .and_then(|output| self.output_identity_for(output)); - options.set_output_identity(output_identity.as_deref()); - Some(options) + self.session_home + .options_for_output(output_identity.as_deref()) } pub(super) fn finish_open_home_session(&mut self, report: RuntimeHomeSessionReport) { @@ -45,7 +44,7 @@ impl WaylandState { return; } - self.session_target_committed(); + self.report_session_to_daemon(); self.set_session_toolbar_info(format!("Returned to {}", self.session_home.label())); } From 7654b2428590a073b1ea8e47ba836c31c3a3e825 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:25:13 +0200 Subject: [PATCH 08/14] fix(session): keep the current session when home cannot load Return home handed what the load found to the startup handler, which starts on an empty canvas for a session that is not a regular file or one too large to restore. A home that had become a directory, or had grown past the load cap, cleared the current canvas and history, switched target and was reported to the daemon as home. Home now commits inside the session transaction, through the same launch-style outcome handling the startup load uses, now shared, so the transaction's guards cover the whole switch. Those two outcomes fail the command and leave the current session as it was, and a named home is checked like a startup session file before it loads. A home without persistence is applied in the transaction too. After home commits, the overlay rechecks its output: a per-output home follows an output change that happened while it loaded. The Session menu offers the way home only while a persisted session is active, since without one the overlay is in a home it cannot leave, and the row goes through the same item table as the other session controls. --- src/backend/wayland/session.rs | 8 +- src/backend/wayland/session/driver.rs | 4 +- src/backend/wayland/session/driver/tests.rs | 101 +++++++++--- src/backend/wayland/session/load_outcome.rs | 125 +++++++++++++++ src/backend/wayland/session/persistence.rs | 6 + .../wayland/session/persistence/worker.rs | 8 + src/backend/wayland/session/runtime.rs | 10 -- .../wayland/session/runtime/transaction.rs | 20 ++- .../session/runtime/transaction/phases.rs | 66 +++++++- src/backend/wayland/state/core/output.rs | 16 +- .../wayland/state/core/output/session_ops.rs | 148 ++++-------------- .../wayland/state/core/output/tests.rs | 3 +- .../wayland/state/toolbar/events/session.rs | 2 +- .../state/toolbar/events/session/home.rs | 64 ++------ src/ui/toolbar/model/mod.rs | 1 + src/ui/toolbar/model/session.rs | 21 +-- 16 files changed, 352 insertions(+), 251 deletions(-) create mode 100644 src/backend/wayland/session/load_outcome.rs diff --git a/src/backend/wayland/session.rs b/src/backend/wayland/session.rs index 4caa6293..ae233b72 100644 --- a/src/backend/wayland/session.rs +++ b/src/backend/wayland/session.rs @@ -602,6 +602,7 @@ fn autosave_active(options: &SessionOptions) -> bool { pub(in crate::backend::wayland) mod driver; mod home; +mod load_outcome; mod persistence; mod runtime; @@ -610,14 +611,17 @@ pub(in crate::backend::wayland) use home::HomeSession; pub(in crate::backend::wayland) use home::{ SessionHome, SessionLaunch, load_output_session, session_target, }; +pub(in crate::backend::wayland) use load_outcome::{ + ExpandedTooLarge, apply_load_outcome, replace_output_session_snapshot, +}; pub(in crate::backend::wayland) use persistence::{ PersistenceCompletion, PersistenceController, PersistenceOperation, PersistenceOutcome, RequestId, SaveCompletion, SaveStrategy, SubmitFailure, }; pub(in crate::backend::wayland) use runtime::{ - ExplicitSessionTransaction, RuntimeHomeSessionReport, SessionCommand, SessionCommandReport, - SessionTransaction, TransactionStep, + ExplicitSessionTransaction, SessionCommand, SessionCommandReport, SessionTransaction, + TransactionStep, }; #[cfg(test)] pub(in crate::backend::wayland) use runtime::{ diff --git a/src/backend/wayland/session/driver.rs b/src/backend/wayland/session/driver.rs index 0298694d..ab1a55b5 100644 --- a/src/backend/wayland/session/driver.rs +++ b/src/backend/wayland/session/driver.rs @@ -144,7 +144,9 @@ fn advance_session_command( Ok(TransactionStep::Complete(report)) => { if matches!( *report, - SessionCommandReport::Open(_) | SessionCommandReport::Clear(_) + SessionCommandReport::Open(_) + | SessionCommandReport::Home + | SessionCommandReport::Clear(_) ) { runtime.refresh_session_ui_seeds(); } diff --git a/src/backend/wayland/session/driver/tests.rs b/src/backend/wayland/session/driver/tests.rs index 481049f1..5682849d 100644 --- a/src/backend/wayland/session/driver/tests.rs +++ b/src/backend/wayland/session/driver/tests.rs @@ -726,6 +726,7 @@ fn returning_home_saves_the_current_session_then_loads_home_like_a_launch() { let measurer = TextMeasurer::default(); let (persistence, worker) = PersistenceController::controlled_for_test(); let mut runtime = CommandRuntime::new(&mut input, &measurer, &mut session, persistence); + let epoch = runtime.session.target_epoch(); start_session_command( &mut runtime, @@ -739,24 +740,68 @@ fn returning_home_saves_the_current_session_then_loads_home_like_a_launch() { worker.complete_next(); // load home runtime.receive(); - assert!(runtime.errors.is_empty()); - let [SessionCommandReport::Home(report)] = runtime.reports.as_slice() else { - panic!( - "expected a home report, got {} reports", - runtime.reports.len() - ); - }; - assert_eq!( - report.options.as_ref().map(|options| &options.target), - Some(&home.target) - ); + assert!(runtime.errors.is_empty(), "{:?}", runtime.errors); assert!(matches!( - report.outcome, - Some(stored_session::LoadSnapshotOutcome::Loaded(_)) + runtime.reports.as_slice(), + [SessionCommandReport::Home] )); - // The runtime applies home; until then the canvas and target stay put. - assert_eq!(runtime.session.options().unwrap().target, current.target); - assert_eq!(runtime.input.boards.active_frame().shapes.len(), 1); + assert_eq!(runtime.session.options().unwrap().target, home.target); + assert_ne!(runtime.session.target_epoch(), epoch); + assert!(!runtime.session.is_dirty() && !runtime.input.is_session_dirty()); + let shapes = &runtime.input.boards.active_frame().shapes; + assert_eq!(shapes.len(), 1); + assert!(matches!( + shapes[0].shape, + crate::draw::Shape::Line { x2: 42, .. } + )); + // The commit was announced before the terminal report. + assert_eq!( + runtime.committed_targets.last(), + Some(&(Some(home.target.clone()), 0)) + ); +} + +#[test] +fn a_home_that_cannot_be_loaded_leaves_the_current_session() { + let temp = crate::test_temp::tempdir().unwrap(); + let current = named_options(temp.path(), "current"); + let configured = configured_home(temp.path()); + // A launch would start empty on these; a return home refuses them. + std::fs::create_dir_all(configured.session_file_path()).unwrap(); + let named = named_options(temp.path(), "named-home"); + std::fs::create_dir(named.session_file_path()).unwrap(); + + for (home, expected) in [(configured, "not a regular file"), (named, "directory")] { + let mut input = test_input_state(); + add_line(&mut input, 51); + input.mark_session_dirty(); + let mut session = SessionState::new(Some(current.clone())); + let measurer = TextMeasurer::default(); + let (persistence, worker) = PersistenceController::controlled_for_test(); + let mut runtime = CommandRuntime::new(&mut input, &measurer, &mut session, persistence); + + start_session_command(&mut runtime, SessionCommand::OpenHome(Some(Box::new(home)))) + .unwrap(); + worker.complete_next(); // save the current session + runtime.receive(); + worker.complete_next(); // load home + runtime.receive(); + + assert!(runtime.reports.is_empty()); + assert!( + format!("{:#}", runtime.errors[0]).contains(expected), + "{:?}", + runtime.errors + ); + assert_eq!(runtime.session.options().unwrap().target, current.target); + let shapes = &runtime.input.boards.active_frame().shapes; + assert_eq!(shapes.len(), 1, "{expected}"); + assert!(matches!( + shapes[0].shape, + crate::draw::Shape::Line { x2: 51, .. } + )); + assert!(runtime.committed_targets.is_empty()); + } } #[test] @@ -785,24 +830,34 @@ fn returning_home_is_refused_when_the_canvas_changes_while_home_loads() { runtime.errors ); assert_eq!(runtime.session.options().unwrap().target, current.target); + assert_eq!(runtime.input.boards.active_frame().shapes.len(), 1); } #[test] -fn returning_to_a_home_without_persistence_loads_nothing() { +fn returning_to_a_home_without_persistence_leaves_an_unsaved_empty_canvas() { let temp = crate::test_temp::tempdir().unwrap(); + let current = named_options(temp.path(), "current"); let mut input = test_input_state(); - let mut session = SessionState::new(Some(named_options(temp.path(), "current"))); + add_line(&mut input, 51); + input.mark_session_dirty(); + let mut session = SessionState::new(Some(current.clone())); let measurer = TextMeasurer::default(); let (persistence, worker) = PersistenceController::controlled_for_test(); let mut runtime = CommandRuntime::new(&mut input, &measurer, &mut session, persistence); start_session_command(&mut runtime, SessionCommand::OpenHome(None)).unwrap(); + worker.complete_next(); // save the current session + runtime.receive(); - assert!(!worker.has_request()); - let [SessionCommandReport::Home(report)] = runtime.reports.as_slice() else { - panic!("expected a home report"); - }; - assert!(report.options.is_none() && report.outcome.is_none()); + assert!(!worker.has_request(), "nothing to load"); + assert_eq!(loaded_line_x2(¤t), 51); + assert!(matches!( + runtime.reports.as_slice(), + [SessionCommandReport::Home] + )); + assert!(runtime.session.options().is_none()); + assert!(runtime.input.boards.active_frame().shapes.is_empty()); + assert_eq!(runtime.committed_targets.last(), Some(&(None, 0))); } #[test] diff --git a/src/backend/wayland/session/load_outcome.rs b/src/backend/wayland/session/load_outcome.rs new file mode 100644 index 00000000..7f3a3fbb --- /dev/null +++ b/src/backend/wayland/session/load_outcome.rs @@ -0,0 +1,125 @@ +//! What a launch-style load found, put on the canvas. A launch, an output's +//! session load and a return to the home session share these rules. + +use std::path::PathBuf; + +use anyhow::Result; +use log::{debug, warn}; + +use crate::input::InputState; +use crate::input::state::{Toast, ToastPriority}; +use crate::session::{ + self as stored_session, LoadSnapshotOutcome, SessionOptions, SessionSnapshot, +}; + +/// A session left on disk unloaded because it expands beyond the safety cap. +#[derive(Debug)] +pub(in crate::backend::wayland) struct ExpandedTooLarge { + pub(in crate::backend::wayland) path: PathBuf, + pub(in crate::backend::wayland) max_expanded_size: u64, +} + +/// Replaces every board with what loading `options` found, and gives the +/// notices a launch gives for a backup, recovery or unreadable session. A +/// session too large to restore is returned for the caller to protect and +/// report. +pub(in crate::backend::wayland) fn apply_load_outcome( + input_state: &mut InputState, + measurer: &crate::draw::TextMeasurer, + outcome: LoadSnapshotOutcome, + options: &SessionOptions, + context: &str, +) -> Result> { + match outcome { + LoadSnapshotOutcome::Loaded(snapshot) => { + debug!( + "Restoring session {} from {}", + context, + options.session_file_path().display() + ); + replace_output_session_snapshot(input_state, measurer, Some(*snapshot), options)?; + } + LoadSnapshotOutcome::LoadedFromBackup(snapshot) => { + warn!( + "Restoring session {} from backup {} because the primary session had no board data", + context, + options.backup_file_path().display() + ); + replace_output_session_snapshot(input_state, measurer, Some(*snapshot), options)?; + input_state.push_toast(ToastPriority::Info, "output", Toast::warning("Restored drawings from the session backup; the primary session had no board data.")); + } + LoadSnapshotOutcome::LoadedFromRecovery(snapshot) => { + debug!( + "Restoring session {} from recovery artifact {}", + context, + options.recovery_file_path().display() + ); + replace_output_session_snapshot(input_state, measurer, Some(*snapshot), options)?; + input_state.push_toast(ToastPriority::Info, "output", Toast::warning("Restored session from recovery file; normal save previously exceeded the size limit.")); + } + LoadSnapshotOutcome::Empty => { + debug!( + "No session data found for {} ({})", + options.session_file_path().display(), + context + ); + replace_output_session_snapshot(input_state, measurer, None, options)?; + } + LoadSnapshotOutcome::EmptyAfterCorruption { backup_path } => { + // An empty canvas here is indistinguishable from "no session + // yet", so without this the user's drawings appear to have + // vanished and only the log says the bytes were kept. + warn!( + "Session {} could not be read for {}; its bytes were preserved at {}", + options.session_file_path().display(), + context, + backup_path.display() + ); + replace_output_session_snapshot(input_state, measurer, None, options)?; + input_state.push_toast( + ToastPriority::Critical, + "session.corrupt", + Toast::error(format!( + "Previous session could not be read; a copy was saved to {}", + backup_path.display() + )) + .duration_ms(20_000), + ); + } + LoadSnapshotOutcome::NonRegularArtifact { path } => { + debug!( + "Skipping non-regular session artifact {} for {}", + path.display(), + context + ); + replace_output_session_snapshot(input_state, measurer, None, options)?; + } + LoadSnapshotOutcome::ExpandedTooLarge { + path, + max_expanded_size, + } => { + replace_output_session_snapshot(input_state, measurer, None, options)?; + return Ok(Some(ExpandedTooLarge { + path, + max_expanded_size, + })); + } + } + + Ok(None) +} + +/// Replaces every board with `snapshot`, or with empty boards without one. +pub(in crate::backend::wayland) fn replace_output_session_snapshot( + input_state: &mut InputState, + measurer: &crate::draw::TextMeasurer, + snapshot: Option, + options: &SessionOptions, +) -> Result<()> { + let snapshot = snapshot.unwrap_or_else(|| SessionSnapshot { + active_board_id: input_state.board_id().to_string(), + boards: Vec::new(), + tool_state: None, + }); + stored_session::apply_snapshot_replacing_boards(input_state, measurer, snapshot, options) +} diff --git a/src/backend/wayland/session/persistence.rs b/src/backend/wayland/session/persistence.rs index 37b847fe..fcebc02d 100644 --- a/src/backend/wayland/session/persistence.rs +++ b/src/backend/wayland/session/persistence.rs @@ -52,6 +52,11 @@ pub(in crate::backend::wayland) enum PersistenceOperation { LoadNamedCandidate { options: SessionOptions, }, + /// Loads the home session as a launch would, after the startup checks a + /// named home file gets. + LoadHome { + options: SessionOptions, + }, /// Loads a remembered session file only while it is still a usable /// session file, before and after it loads. LoadRemembered { @@ -98,6 +103,7 @@ impl PersistenceOperation { Self::SaveAs { .. } => "save-as", Self::LoadConfigured { .. } => "load-configured", Self::LoadNamedCandidate { .. } => "load-named-candidate", + Self::LoadHome { .. } => "load-home", Self::LoadRemembered { .. } => "load-remembered", Self::Inspect { .. } => "inspect", Self::SaveAsOverwritePreflight { .. } => "save-as-overwrite-preflight", diff --git a/src/backend/wayland/session/persistence/worker.rs b/src/backend/wayland/session/persistence/worker.rs index 832cf072..7978e50b 100644 --- a/src/backend/wayland/session/persistence/worker.rs +++ b/src/backend/wayland/session/persistence/worker.rs @@ -134,6 +134,14 @@ pub(super) fn execute(operation: PersistenceOperation) -> Result Ok(PersistenceOutcome::Load( session::load_named_session_candidate(&options)?, )), + PersistenceOperation::LoadHome { options } => { + if options.is_named_file() { + session::validate_named_session_file_for_foreground(&options.session_file_path())?; + } + Ok(PersistenceOutcome::Load( + session::load_snapshot_with_outcome(&options)?, + )) + } PersistenceOperation::LoadRemembered { options } => load_remembered(&options), PersistenceOperation::Inspect { options } => Ok(PersistenceOutcome::Inspection( session::inspect_session(&options)?, diff --git a/src/backend/wayland/session/runtime.rs b/src/backend/wayland/session/runtime.rs index 206531eb..5a2f2bb2 100644 --- a/src/backend/wayland/session/runtime.rs +++ b/src/backend/wayland/session/runtime.rs @@ -11,16 +11,6 @@ pub(in crate::backend::wayland) struct RuntimeOpenSessionReport { pub catalog_error: Option, } -/// A return to the home session that saved the current session and loaded -/// home, for the runtime to apply. -#[derive(Debug)] -pub(in crate::backend::wayland) struct RuntimeHomeSessionReport { - /// Home's options; `None` when home has persistence disabled. - pub options: Option, - /// What loading home found; `None` without persistence. - pub outcome: Option, -} - #[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(in crate::backend::wayland) struct RuntimeSaveAsSessionReport { diff --git a/src/backend/wayland/session/runtime/transaction.rs b/src/backend/wayland/session/runtime/transaction.rs index 89a1b757..1c365207 100644 --- a/src/backend/wayland/session/runtime/transaction.rs +++ b/src/backend/wayland/session/runtime/transaction.rs @@ -19,7 +19,8 @@ pub(in crate::backend::wayland) enum SessionCommand { pub(in crate::backend::wayland) enum SessionCommandReport { Open(RuntimeOpenSessionReport), - Home(RuntimeHomeSessionReport), + /// The overlay is back in its home session. + Home, SaveAs(RuntimeSaveAsSessionReport), Overwrite(PathBuf, bool), Clear(RuntimeClearSessionReport), @@ -207,17 +208,14 @@ impl ExplicitSessionTransaction { SessionCommand::OpenHome(Some(options)) => { let options = (**options).clone(); self.capture_input_generation(context); - self.work( - Phase::LoadHome, - PersistenceOperation::LoadConfigured { options }, - ) + self.work(Phase::LoadHome, PersistenceOperation::LoadHome { options }) + } + SessionCommand::OpenHome(None) => { + self.leave_persistence_for_home(context)?; + Ok(TransactionStep::Complete(Box::new( + SessionCommandReport::Home, + ))) } - SessionCommand::OpenHome(None) => Ok(TransactionStep::Complete(Box::new( - SessionCommandReport::Home(RuntimeHomeSessionReport { - options: None, - outcome: None, - }), - ))), SessionCommand::SaveAs(_, _) => Ok(TransactionStep::Complete(Box::new( SessionCommandReport::SaveAs(RuntimeSaveAsSessionReport { previous_path: self.current_path(), diff --git a/src/backend/wayland/session/runtime/transaction/phases.rs b/src/backend/wayland/session/runtime/transaction/phases.rs index 93037b27..fb190eb9 100644 --- a/src/backend/wayland/session/runtime/transaction/phases.rs +++ b/src/backend/wayland/session/runtime/transaction/phases.rs @@ -172,11 +172,12 @@ impl ExplicitSessionTransaction { ))) } - /// Home is applied by the runtime, which loads it like a launch would; the - /// transaction only hands over what the load found. + /// Commits home once it has loaded the way a launch would load it. What a + /// launch would only start empty on, a session that is not a regular file + /// or one too large to restore, leaves the current session in place. pub(super) fn complete_load_home( &mut self, - _context: &mut SessionTransaction<'_>, + context: &mut SessionTransaction<'_>, outcome: Option, ) -> Result { let PersistenceOutcome::Load(load) = required_outcome(outcome)? else { @@ -185,14 +186,65 @@ impl ExplicitSessionTransaction { let SessionCommand::OpenHome(Some(options)) = &self.command else { unreachable!() }; + let options = (**options).clone(); + match &load { + LoadSnapshotOutcome::NonRegularArtifact { path } => { + return Err(anyhow!( + "home session {} is not a regular file", + path.display() + )); + } + LoadSnapshotOutcome::ExpandedTooLarge { + path, + max_expanded_size, + } => { + return Err(anyhow!( + "home session {} expands beyond the {max_expanded_size} byte safety limit", + path.display() + )); + } + _ => {} + } + + let loaded_board_data = load.has_board_data(); + apply_load_outcome( + context.input_state, + context.measurer, + load, + &options, + "home session", + )?; + context + .input_state + .set_session_preflight_options(Some(options.clone())); + context.input_state.clear_session_dirty(); + context + .session + .commit_output_options(options, loaded_board_data); + Ok(TransactionStep::Complete(Box::new( - SessionCommandReport::Home(RuntimeHomeSessionReport { - options: Some((**options).clone()), - outcome: Some(load), - }), + SessionCommandReport::Home, ))) } + /// Home has persistence disabled: the run continues on an empty canvas + /// that is not saved, as it would have started. + pub(super) fn leave_persistence_for_home( + &mut self, + context: &mut SessionTransaction<'_>, + ) -> Result<()> { + let current = self + .current + .as_ref() + .expect("return home has current options"); + replace_output_session_snapshot(context.input_state, context.measurer, None, current)?; + + context.input_state.set_session_preflight_options(None); + context.input_state.clear_session_dirty(); + context.session.commit_without_persistence(); + Ok(()) + } + pub(super) fn complete_save_as_preflight( &mut self, context: &mut SessionTransaction<'_>, diff --git a/src/backend/wayland/state/core/output.rs b/src/backend/wayland/state/core/output.rs index abfab083..97ac8756 100644 --- a/src/backend/wayland/state/core/output.rs +++ b/src/backend/wayland/state/core/output.rs @@ -1,5 +1,5 @@ use crate::input::state::{Toast, ToastPriority}; -use log::{debug, info, warn}; +use log::{info, warn}; use smithay_client_toolkit::shell::{WaylandSurface, wlr_layer::Anchor}; use std::time::{Duration, Instant}; @@ -72,19 +72,5 @@ fn live_source_reconciliation_ready( && worker_healthy } -fn replace_output_session_snapshot( - input_state: &mut crate::input::InputState, - measurer: &crate::draw::TextMeasurer, - snapshot: Option, - options: &session::SessionOptions, -) -> anyhow::Result<()> { - let snapshot = snapshot.unwrap_or_else(|| SessionSnapshot { - active_board_id: input_state.board_id().to_string(), - boards: Vec::new(), - tool_state: None, - }); - session::apply_snapshot_replacing_boards(input_state, measurer, snapshot, options) -} - #[cfg(test)] mod tests; diff --git a/src/backend/wayland/state/core/output/session_ops.rs b/src/backend/wayland/state/core/output/session_ops.rs index 997d26df..d56b11bb 100644 --- a/src/backend/wayland/state/core/output/session_ops.rs +++ b/src/backend/wayland/state/core/output/session_ops.rs @@ -1,5 +1,5 @@ use super::*; -use crate::backend::wayland::session::load_output_session; +use crate::backend::wayland::session::{ExpandedTooLarge, apply_load_outcome, load_output_session}; impl WaylandState { /// Loads and commits `staged` as the session for the output identified as @@ -98,130 +98,40 @@ impl WaylandState { options: &session::SessionOptions, context: &str, ) -> anyhow::Result<()> { - match outcome { - session::LoadSnapshotOutcome::Loaded(snapshot) => { - debug!( - "Restoring session {} from {}", - context, - options.session_file_path().display() - ); - replace_output_session_snapshot( - &mut self.input_state, - self.render.text_measurer(), - Some(*snapshot), - options, - )?; - } - session::LoadSnapshotOutcome::LoadedFromBackup(snapshot) => { - warn!( - "Restoring session {} from backup {} because the primary session had no board data", - context, - options.backup_file_path().display() - ); - replace_output_session_snapshot( - &mut self.input_state, - self.render.text_measurer(), - Some(*snapshot), - options, - )?; - self.input_state.push_toast(ToastPriority::Info, "output", Toast::warning("Restored drawings from the session backup; the primary session had no board data.")); - } - session::LoadSnapshotOutcome::LoadedFromRecovery(snapshot) => { - debug!( - "Restoring session {} from recovery artifact {}", - context, - options.recovery_file_path().display() - ); - replace_output_session_snapshot( - &mut self.input_state, - self.render.text_measurer(), - Some(*snapshot), - options, - )?; - self.input_state.push_toast(ToastPriority::Info, "output", Toast::warning("Restored session from recovery file; normal save previously exceeded the size limit.")); - } - session::LoadSnapshotOutcome::Empty => { - debug!( - "No session data found for {} ({})", - options.session_file_path().display(), - context - ); - replace_output_session_snapshot( - &mut self.input_state, - self.render.text_measurer(), - None, - options, - )?; - } - session::LoadSnapshotOutcome::EmptyAfterCorruption { backup_path } => { - // An empty canvas here is indistinguishable from "no session - // yet", so without this the user's drawings appear to have - // vanished and only the log says the bytes were kept. - warn!( - "Session {} could not be read for {}; its bytes were preserved at {}", - options.session_file_path().display(), - context, - backup_path.display() - ); - replace_output_session_snapshot( - &mut self.input_state, - self.render.text_measurer(), - None, - options, - )?; - self.input_state.push_toast( - ToastPriority::Critical, - "session.corrupt", - Toast::error(format!( - "Previous session could not be read; a copy was saved to {}", - backup_path.display() - )) - .duration_ms(20_000), - ); - } - session::LoadSnapshotOutcome::NonRegularArtifact { path } => { - debug!( - "Skipping non-regular session artifact {} for {}", - path.display(), - context - ); - replace_output_session_snapshot( - &mut self.input_state, - self.render.text_measurer(), - None, - options, - )?; - } - session::LoadSnapshotOutcome::ExpandedTooLarge { - path, - max_expanded_size, - } => { - replace_output_session_snapshot( - &mut self.input_state, - self.render.text_measurer(), - None, - options, - )?; - self.session.protect_session_path(path.clone()); - if self.session.mark_expanded_load_notified(&path) { - notification::send_notification_async( - &self.tokio_handle, - "Session Too Large to Restore".to_string(), - format!( - "The saved session was left unchanged because it expands beyond the {} MiB safety cap. Clear the session or move {} if it is no longer needed.", - max_expanded_size / 1024 / 1024, - path.display() - ), - Some("dialog-warning".to_string()), - ); - } - } + if let Some(too_large) = apply_load_outcome( + &mut self.input_state, + self.render.text_measurer(), + outcome, + options, + context, + )? { + self.protect_too_large_session(too_large); } self.refresh_runtime_ui_config_seeds(); self.mark_clean_after_session_load(); Ok(()) } + fn protect_too_large_session(&mut self, too_large: ExpandedTooLarge) { + let ExpandedTooLarge { + path, + max_expanded_size, + } = too_large; + self.session.protect_session_path(path.clone()); + if self.session.mark_expanded_load_notified(&path) { + notification::send_notification_async( + &self.tokio_handle, + "Session Too Large to Restore".to_string(), + format!( + "The saved session was left unchanged because it expands beyond the {} MiB safety cap. Clear the session or move {} if it is no longer needed.", + max_expanded_size / 1024 / 1024, + path.display() + ), + Some("dialog-warning".to_string()), + ); + } + } + fn mark_clean_after_session_load(&mut self) { self.input_state.clear_session_dirty(); self.session.mark_clean_after_load(); diff --git a/src/backend/wayland/state/core/output/tests.rs b/src/backend/wayland/state/core/output/tests.rs index d64380dd..d933b05f 100644 --- a/src/backend/wayland/state/core/output/tests.rs +++ b/src/backend/wayland/state/core/output/tests.rs @@ -1,7 +1,8 @@ use super::{ OutputTransitionStart, live_source_reconciliation_ready, output_transition_retry_at, - output_transition_start, replace_output_session_snapshot, + output_transition_start, }; +use crate::backend::wayland::session::replace_output_session_snapshot; use crate::{ backend::wayland::session::SessionState, draw::{Color, Frame, Shape}, diff --git a/src/backend/wayland/state/toolbar/events/session.rs b/src/backend/wayland/state/toolbar/events/session.rs index e6e1f813..8b14a7b2 100644 --- a/src/backend/wayland/state/toolbar/events/session.rs +++ b/src/backend/wayland/state/toolbar/events/session.rs @@ -427,7 +427,7 @@ impl WaylandState { self.set_session_toolbar_info(format!("Opened session {name}")); } } - SessionCommandReport::Home(report) => self.finish_open_home_session(report), + SessionCommandReport::Home => self.finish_open_home_session(), SessionCommandReport::SaveAs(report) => { self.clear_toolbar_save_as_overwrite_prompt(); self.set_session_toolbar_info(format!( diff --git a/src/backend/wayland/state/toolbar/events/session/home.rs b/src/backend/wayland/state/toolbar/events/session/home.rs index b8e3704d..61d2c4ad 100644 --- a/src/backend/wayland/state/toolbar/events/session/home.rs +++ b/src/backend/wayland/state/toolbar/events/session/home.rs @@ -1,6 +1,5 @@ use super::*; -use crate::backend::wayland::session::RuntimeHomeSessionReport; -use crate::session::{SessionOptions, SessionSnapshot}; +use crate::session::SessionOptions; impl WaylandState { /// Returns to the home session the way Open switches session: the current @@ -16,60 +15,23 @@ impl WaylandState { /// Home's options for the output the overlay is on now, not those of the /// named session it is leaving. fn home_session_options(&self) -> Option { - let output_identity = self - .surface + self.session_home + .options_for_output(self.current_output_identity().as_deref()) + } + + fn current_output_identity(&self) -> Option { + self.surface .current_output() .as_ref() - .and_then(|output| self.output_identity_for(output)); - self.session_home - .options_for_output(output_identity.as_deref()) + .and_then(|output| self.output_identity_for(output)) } - pub(super) fn finish_open_home_session(&mut self, report: RuntimeHomeSessionReport) { - let applied = match (report.options, report.outcome) { - (Some(options), Some(outcome)) => { - let loaded_board_data = outcome.has_board_data(); - self.handle_session_load_outcome_for_options(outcome, &options, "home session") - .map(|()| { - self.input_state - .set_session_preflight_options(Some(options.clone())); - self.session - .commit_output_options(options, loaded_board_data); - }) - } - _ => self.leave_persistence_for_home(), - }; - if let Err(error) = applied { - self.report_session_command_error("Return to the home session failed", &error); - return; - } + /// Home is committed. The overlay may have moved to another output while + /// it loaded, and a per-output home follows it there. + pub(super) fn finish_open_home_session(&mut self) { + let output_identity = self.current_output_identity(); + self.begin_session_output_transition(output_identity, "return to the home session"); - self.report_session_to_daemon(); self.set_session_toolbar_info(format!("Returned to {}", self.session_home.label())); } - - /// Home has persistence disabled: the run continues on an empty canvas - /// that is not saved, as it would have started. - fn leave_persistence_for_home(&mut self) -> Result<()> { - let current = self - .session_options() - .cloned() - .context("no session to return home from")?; - let empty = SessionSnapshot { - active_board_id: self.input_state.board_id().to_string(), - boards: Vec::new(), - tool_state: None, - }; - crate::session::apply_snapshot_replacing_boards( - &mut self.input_state, - self.render.text_measurer(), - empty, - ¤t, - )?; - self.input_state.set_session_preflight_options(None); - self.input_state.clear_session_dirty(); - self.session.commit_without_persistence(); - self.refresh_runtime_ui_config_seeds(); - Ok(()) - } } diff --git a/src/ui/toolbar/model/mod.rs b/src/ui/toolbar/model/mod.rs index 75aab0a5..3f5e9c83 100644 --- a/src/ui/toolbar/model/mod.rs +++ b/src/ui/toolbar/model/mod.rs @@ -684,6 +684,7 @@ mod tests { .any(|button| button.event == ToolbarEvent::SessionInfo), "hiding side.session.info removes the Session Info control" ); + snapshot.active_session_path = Some("/tmp/b.wayscriber-session".into()); assert!( ToolbarSessionModel::for_popover(&snapshot) .expect("session") diff --git a/src/ui/toolbar/model/session.rs b/src/ui/toolbar/model/session.rs index aaff8aab..98ad4d5f 100644 --- a/src/ui/toolbar/model/session.rs +++ b/src/ui/toolbar/model/session.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use crate::config::{ToolbarItemId, toolbar_item_ids as ids}; +use super::super::session_format::session_display_name; use super::super::{SessionRecentSnapshot, ToolbarEvent, ToolbarSnapshot}; const MAX_RECENT_SESSIONS: usize = 5; @@ -45,8 +46,11 @@ impl ToolbarSessionModel { .into_iter() .filter(|button| session_button_visible(snapshot, &button.event)) .collect(); - let home = (!snapshot.toolbar_item_hidden(ids::SIDE_SESSION_HOME)) - .then(|| ToolbarSessionHome::from_snapshot(snapshot)); + // Without a persisted session the overlay is in a home that cannot be + // left, so there is no way back to offer. + let home = (target_active + && session_button_visible(snapshot, &ToolbarEvent::OpenHomeSession)) + .then(|| ToolbarSessionHome::from_snapshot(snapshot)); let recents = if target_active { snapshot .recent_sessions @@ -61,7 +65,7 @@ impl ToolbarSessionModel { .pending_save_as_overwrite_path .as_ref() .map(|path| ToolbarSessionOverwriteConfirmation { - label: session_path_label(path), + label: session_display_name(path), path: path.clone(), }); @@ -158,13 +162,6 @@ impl ToolbarSessionRecent { } } -fn session_path_label(path: &std::path::Path) -> String { - path.file_name() - .and_then(|name| name.to_str()) - .map(str::to_string) - .unwrap_or_else(|| path.display().to_string()) -} - fn session_button_visible(snapshot: &ToolbarSnapshot, event: &ToolbarEvent) -> bool { session_button_item_id(event).is_none_or(|id| !snapshot.toolbar_item_hidden(id)) } @@ -173,6 +170,7 @@ fn session_button_item_id(event: &ToolbarEvent) -> Option { Some(match event { ToolbarEvent::OpenSession => ids::SIDE_SESSION_OPEN, ToolbarEvent::SaveSessionAs => ids::SIDE_SESSION_SAVE_AS, + ToolbarEvent::OpenHomeSession => ids::SIDE_SESSION_HOME, ToolbarEvent::SessionInfo => ids::SIDE_SESSION_INFO, ToolbarEvent::ClearSession => ids::SIDE_SESSION_CLEAR, ToolbarEvent::OpenConfigurator => ids::SIDE_SESSION_MANAGER, @@ -196,6 +194,7 @@ mod tests { for (event, expected) in [ (ToolbarEvent::OpenSession, ids::SIDE_SESSION_OPEN), (ToolbarEvent::SaveSessionAs, ids::SIDE_SESSION_SAVE_AS), + (ToolbarEvent::OpenHomeSession, ids::SIDE_SESSION_HOME), (ToolbarEvent::SessionInfo, ids::SIDE_SESSION_INFO), (ToolbarEvent::ClearSession, ids::SIDE_SESSION_CLEAR), (ToolbarEvent::OpenConfigurator, ids::SIDE_SESSION_MANAGER), @@ -247,6 +246,8 @@ mod tests { ToolbarEvent::OpenConfigurator )); assert!(model.recents.is_empty()); + // A home that cannot be left offers no way back to it. + assert!(model.home.is_none()); assert_eq!(model.active_path_label, "No persisted session target"); } From 7aba636e8c5bfaf196f7c8749a514a5a76718d08 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:31:15 +0200 Subject: [PATCH 09/14] fix(daemon): harden reading and removing overlay session reports Reports are read and removed only through a report directory that is a real directory private to this user, so a symlinked overlay-targets/ is neither read through nor cleaned through. A reported session file must also pass the shape rules every named session file follows, not only be absolute. When a stop forces the child down because its broker failed, the child was still retired and its final report read; the report now travels with the error to the daemon instead of being dropped. The private-file reader and the private-directory helper are now shared: the report reader uses the bounded protocol reader with a privacy check, and the command layout, the action journal and the reports create their directories through one helper. The daemon reads a trusted report through one function that logs and ignores what it cannot trust. --- src/daemon/overlay/lifecycle/stop.rs | 47 +++-- src/daemon/overlay/mod.rs | 9 +- src/daemon/protocol_v2/action.rs | 16 +- src/daemon/protocol_v2/child.rs | 9 +- src/daemon/protocol_v2/command/layout.rs | 20 +-- src/daemon/protocol_v2/linux.rs | 44 +++++ src/daemon/protocol_v2/session_target.rs | 165 +++++++----------- .../protocol_v2/session_target/tests.rs | 22 ++- 8 files changed, 178 insertions(+), 154 deletions(-) diff --git a/src/daemon/overlay/lifecycle/stop.rs b/src/daemon/overlay/lifecycle/stop.rs index 6e43bfe0..e8d650a6 100644 --- a/src/daemon/overlay/lifecycle/stop.rs +++ b/src/daemon/overlay/lifecycle/stop.rs @@ -10,9 +10,31 @@ use crate::daemon::protocol_v2::{ BootClock, ReportedSession, open_overlay_pidfd, wait_for_pidfd_exit, }; +/// A stop that failed. A child forced down after its broker failed was still +/// retired, so the session it last reported comes with the error. +pub(in crate::daemon) struct StopFailure { + pub(in crate::daemon) session: Option, + pub(in crate::daemon) error: anyhow::Error, +} + +impl From for StopFailure { + fn from(error: anyhow::Error) -> Self { + Self { + session: None, + error, + } + } +} + +impl From for StopFailure { + fn from(error: std::io::Error) -> Self { + anyhow::Error::from(error).into() + } +} + impl OverlayLifecycle { /// Stops the child, returning the session it last reported. - fn terminate(&mut self) -> Result> { + fn terminate(&mut self) -> std::result::Result, StopFailure> { let mut session = None; if let Some(pid) = self.child.display_pid() { let stop_started = Instant::now(); @@ -75,16 +97,19 @@ impl OverlayLifecycle { } } Err(err) => { - let forced = self.child.force_kill_and_wait(); - return match forced { - Ok(_) => Err(err).context( - "broker ownership failed while querying overlay; child was forced down", - ), - Err(force_error) => Err(anyhow::anyhow!( + return Err(match self.child.force_kill_and_wait() { + Ok(exit) => StopFailure { + session: exit.session, + error: err.context( + "broker ownership failed while querying overlay; child was forced down", + ), + }, + Err(force_error) => anyhow::anyhow!( "broker ownership failed while querying overlay: {err:#}; \ forced termination also failed: {force_error:#}" - )), - }; + ) + .into(), + }); } } } @@ -96,7 +121,9 @@ impl OverlayLifecycle { } /// Stops the overlay, returning the session its child last reported. - pub(in crate::daemon::overlay) fn hide(&mut self) -> Result> { + pub(in crate::daemon::overlay) fn hide( + &mut self, + ) -> std::result::Result, StopFailure> { let session = self.terminate()?; self.mark_hidden(); Ok(session) diff --git a/src/daemon/overlay/mod.rs b/src/daemon/overlay/mod.rs index 34ada582..497bce48 100644 --- a/src/daemon/overlay/mod.rs +++ b/src/daemon/overlay/mod.rs @@ -190,8 +190,13 @@ impl Daemon { return Ok(()); } - let session = self.overlay.hide()?; - self.remember_reported_session(session); + match self.overlay.hide() { + Ok(session) => self.remember_reported_session(session), + Err(failure) => { + self.remember_reported_session(failure.session); + return Err(failure.error); + } + } self.discard_pending_launch_options(); Ok(()) } diff --git a/src/daemon/protocol_v2/action.rs b/src/daemon/protocol_v2/action.rs index bf7e3ccd..ea3eb711 100644 --- a/src/daemon/protocol_v2/action.rs +++ b/src/daemon/protocol_v2/action.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, File, OpenOptions}; use std::io::{self, ErrorKind}; use std::os::fd::AsRawFd; -use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow, bail}; @@ -197,19 +197,7 @@ fn action_name(order: u64, identity: &str) -> String { format!("{order:016x}-{identity}.action") } -fn create_private_directory(path: &Path) -> Result<()> { - match fs::create_dir(path) { - Ok(()) => {} - Err(err) if err.kind() == ErrorKind::AlreadyExists => {} - Err(err) => return Err(err.into()), - } - let metadata = fs::symlink_metadata(path)?; - if !metadata.is_dir() || metadata.file_type().is_symlink() { - bail!("{} is not a no-follow action directory", path.display()); - } - fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; - Ok(()) -} +use super::linux::create_private_directory; fn open_journal_lock(root: &Path) -> Result { try_open_journal_lock(root, false)? diff --git a/src/daemon/protocol_v2/child.rs b/src/daemon/protocol_v2/child.rs index b36e11f5..2da9ce56 100644 --- a/src/daemon/protocol_v2/child.rs +++ b/src/daemon/protocol_v2/child.rs @@ -8,7 +8,7 @@ use anyhow::{Context, Result, anyhow, bail}; use serde::{Deserialize, Serialize}; use super::session_target::{ - ReportedSession, discard_session_report, read_session_report, take_final_session_report, + ReportedSession, discard_session_report, read_trusted_session_report, take_final_session_report, }; use super::wire::fresh_id; @@ -290,12 +290,7 @@ impl OverlayChildOwner { let owned = self.owned.as_ref()?; let generation = self.generation()?; let process_start_ticks = owned.process_start_ticks?; - read_session_report(generation, owned.display_pid, process_start_ticks).unwrap_or_else( - |error| { - log::warn!("Ignoring the session report of overlay child {generation}: {error:#}"); - None - }, - ) + read_trusted_session_report(generation, owned.display_pid, process_start_ticks) } pub(crate) fn try_wait(&mut self) -> Result> { diff --git a/src/daemon/protocol_v2/command/layout.rs b/src/daemon/protocol_v2/command/layout.rs index d2052c0b..4e9b8cab 100644 --- a/src/daemon/protocol_v2/command/layout.rs +++ b/src/daemon/protocol_v2/command/layout.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; use std::fs::{self, File, OpenOptions}; use std::io::{self, ErrorKind}; use std::os::fd::AsRawFd; -use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -58,23 +58,7 @@ pub(super) fn queue_path(root: &Path, order: u64, identity: &str) -> PathBuf { queue_dir(root).join(queue_name(order, identity)) } -pub(super) fn create_private_directory(path: &Path) -> Result<()> { - match fs::create_dir(path) { - Ok(()) => { - fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; - Ok(()) - } - Err(error) if error.kind() == ErrorKind::AlreadyExists => { - let metadata = fs::symlink_metadata(path)?; - if !metadata.is_dir() || metadata.file_type().is_symlink() { - bail!("{} is not a no-follow protocol directory", path.display()); - } - fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; - Ok(()) - } - Err(error) => Err(error.into()), - } -} +pub(super) use super::super::linux::create_private_directory; pub(crate) fn prepare_layout(root: &Path) -> Result<()> { if let Some(parent) = root.parent() { diff --git a/src/daemon/protocol_v2/linux.rs b/src/daemon/protocol_v2/linux.rs index 27a72dfc..d2c007d5 100644 --- a/src/daemon/protocol_v2/linux.rs +++ b/src/daemon/protocol_v2/linux.rs @@ -186,6 +186,44 @@ impl NamespaceIdentity { } pub(crate) fn read_bounded_regular_file(path: &Path, cap: usize) -> io::Result> { + read_bounded_file(path, cap, |_| true) +} + +/// Like [`read_bounded_regular_file`], for a file this user owns and only this +/// user can read or write. +pub(crate) fn read_bounded_private_file(path: &Path, cap: usize) -> io::Result> { + read_bounded_file(path, cap, is_private) +} + +/// Whether this user owns the file or directory and nobody else may use it. +pub(crate) fn is_private(metadata: &std::fs::Metadata) -> bool { + // SAFETY: geteuid has no preconditions and cannot fail. + metadata.uid() == unsafe { libc::geteuid() } && metadata.mode() & 0o077 == 0 +} + +/// Creates `path` as a directory only this user may use, or makes an existing +/// directory so. A symlink or anything but a directory is refused. +pub(crate) fn create_private_directory(path: &Path) -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + + match std::fs::create_dir(path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error.into()), + } + let metadata = std::fs::symlink_metadata(path)?; + if !metadata.is_dir() { + anyhow::bail!("{} is not a no-follow private directory", path.display()); + } + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +fn read_bounded_file( + path: &Path, + cap: usize, + trusted: impl Fn(&std::fs::Metadata) -> bool, +) -> io::Result> { let mut options = OpenOptions::new(); options .read(true) @@ -198,6 +236,12 @@ pub(crate) fn read_bounded_regular_file(path: &Path, cap: usize) -> io::Result Res target, }; let bytes = super::wire::canonical_json(&record, MAX_REPORT_BYTES)?; - create_report_dir()?; + super::linux::create_private_directory(&report_dir())?; crate::durable_io::write_atomic( &report_path(&record.generation), &bytes, @@ -106,41 +106,37 @@ fn report_path_text(path: &Path) -> Result { }) } -fn create_report_dir() -> Result<()> { - use std::os::unix::fs::PermissionsExt; - +/// The report directory if it exists as a real directory private to this +/// user. Reports are neither read nor removed through anything else, such as +/// a symlink to another directory. +fn private_report_dir() -> Result> { let directory = report_dir(); - match std::fs::create_dir(&directory) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + match std::fs::symlink_metadata(&directory) { + Ok(metadata) if metadata.is_dir() && super::linux::is_private(&metadata) => { + Ok(Some(directory)) + } + Ok(_) => bail!("{} is not a private report directory", directory.display()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) => { - return Err(error).with_context(|| format!("failed to create {}", directory.display())); + Err(error).with_context(|| format!("failed to inspect {}", directory.display())) } } - let metadata = std::fs::symlink_metadata(&directory)?; - if !metadata.is_dir() || !owned_by_this_user(&metadata) { - bail!("{} is not a private report directory", directory.display()); - } - std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700))?; - Ok(()) } /// The session the child `generation` last reported, if it reported one under /// exactly this identity. The identity is the one the daemon owns, captured /// while the child ran: an exited child no longer has a `/proc` entry to ask. -pub(crate) fn read_session_report( +fn read_session_report( generation: &str, pid: u32, process_start_ticks: u64, ) -> Result> { super::wire::validate_id(generation)?; - match std::fs::symlink_metadata(report_dir()) { - Ok(metadata) if metadata.is_dir() && is_private(&metadata) => {} - Ok(_) => bail!("the session report directory is not private"), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error).context("failed to inspect the session reports"), - } - let bytes = match read_private_file(&report_path(generation)) { + let Some(directory) = private_report_dir()? else { + return Ok(None); + }; + let path = directory.join(format!("{generation}.target")); + let bytes = match super::linux::read_bounded_private_file(&path, MAX_REPORT_BYTES) { Ok(bytes) => bytes, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(error).context("failed to read the session report"), @@ -154,26 +150,39 @@ pub(crate) fn read_session_report( bail!("the session report belongs to another overlay child"); } - match record.target.map(PathBuf::from) { - None => Ok(Some(ReportedSession::Home)), - Some(path) if path.is_absolute() => Ok(Some(ReportedSession::Named(path))), - Some(path) => bail!("reported session {} is not absolute", path.display()), + let Some(path) = record.target.map(PathBuf::from) else { + return Ok(Some(ReportedSession::Home)); + }; + if !path.is_absolute() { + bail!("reported session {} is not absolute", path.display()); } + // The shape rules any named session file must follow. + crate::session::validate_named_session_file_for_info(&path)?; + Ok(Some(ReportedSession::Named(path))) +} + +/// The session the child `generation` last reported. A report that cannot be +/// trusted is logged and counts as none. +pub(crate) fn read_trusted_session_report( + generation: &str, + pid: u32, + process_start_ticks: u64, +) -> Option { + read_session_report(generation, pid, process_start_ticks).unwrap_or_else(|error| { + log::warn!("Ignoring the session report of overlay child {generation}: {error:#}"); + None + }) } /// Reads the final report of the exited child `generation`, then removes it -/// along with any temporary its writer left. A report that cannot be trusted -/// is logged and ignored, so it never stands in the way of retiring the child. +/// along with any temporary its writer left. An untrusted report never stands +/// in the way of retiring the child. pub(crate) fn take_final_session_report( generation: &str, pid: u32, process_start_ticks: u64, ) -> Option { - let session = - read_session_report(generation, pid, process_start_ticks).unwrap_or_else(|error| { - log::warn!("Ignoring the session report of overlay child {generation}: {error:#}"); - None - }); + let session = read_trusted_session_report(generation, pid, process_start_ticks); discard_session_report(generation); session } @@ -181,14 +190,7 @@ pub(crate) fn take_final_session_report( /// Removes the report of child `generation` and any temporary its writer left. pub(crate) fn discard_session_report(generation: &str) { let report = format!("{generation}.target"); - // The report itself goes by name, however full the directory is. - let removed = match std::fs::remove_file(report_dir().join(&report)) { - Err(error) if error.kind() != std::io::ErrorKind::NotFound => { - Err(anyhow::Error::new(error).context("failed to remove the report")) - } - _ => remove_reports(|target| target == report), - }; - if let Err(error) = removed { + if let Err(error) = remove_reports(Some(&report), |target| target == report) { log::warn!("Failed to remove the session report of overlay child {generation}: {error:#}"); } } @@ -196,7 +198,7 @@ pub(crate) fn discard_session_report(generation: &str) { /// Removes every report an earlier daemon left behind, restoring nothing from /// them: the session a daemon remembered ends with that daemon. pub(crate) fn clear_stale_session_reports() -> Result<()> { - remove_reports(|target| { + remove_reports(None, |target| { target .strip_suffix(".target") .is_some_and(|generation| super::wire::validate_id(generation).is_ok()) @@ -207,79 +209,38 @@ pub(crate) fn clear_stale_session_reports() -> Result<()> { /// a fixed amount of work. const MAX_CLEANUP_ENTRIES: usize = 256; -/// Removes each report, and each temporary left by a report's writer, whose -/// report name satisfies `is_removed`. Anything else in the directory is not -/// this daemon's to remove. -fn remove_reports(is_removed: impl Fn(&str) -> bool) -> Result<()> { - let directory = report_dir(); - let entries = match std::fs::read_dir(&directory) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(error) => { - return Err(error).with_context(|| format!("failed to list {}", directory.display())); - } +/// Removes `report` by name, however full the directory is, then each report, +/// and each temporary left by a report's writer, whose report name satisfies +/// `is_removed`. Anything else in the directory is not this daemon's to remove. +fn remove_reports(report: Option<&str>, is_removed: impl Fn(&str) -> bool) -> Result<()> { + let Some(directory) = private_report_dir()? else { + return Ok(()); }; + if let Some(report) = report { + remove_entry(&directory.join(report))?; + } + + let entries = std::fs::read_dir(&directory) + .with_context(|| format!("failed to list {}", directory.display()))?; for entry in entries.take(MAX_CLEANUP_ENTRIES) { let entry = entry?; let Ok(name) = entry.file_name().into_string() else { continue; }; let report = crate::durable_io::temp_file_target(&name).unwrap_or(&name); - if !is_removed(report) { - continue; - } - match std::fs::remove_file(entry.path()) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error) - .with_context(|| format!("failed to remove {}", entry.path().display())); - } + if is_removed(report) { + remove_entry(&entry.path())?; } } Ok(()) } -/// Reads a regular file this user owns and only this user can read or write, -/// without following a symlink. -fn read_private_file(path: &Path) -> std::io::Result> { - use std::io::Read; - use std::os::unix::fs::OpenOptionsExt; - - let file = std::fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) - .open(path)?; - let metadata = file.metadata()?; - if !metadata.is_file() || !is_private(&metadata) || metadata.len() > MAX_REPORT_BYTES as u64 { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "session report is not a private regular file within its size bound", - )); - } - let mut bytes = Vec::new(); - file.take(MAX_REPORT_BYTES as u64 + 1) - .read_to_end(&mut bytes)?; - if bytes.len() > MAX_REPORT_BYTES { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "session report exceeds its size bound", - )); +fn remove_entry(path: &Path) -> Result<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| format!("failed to remove {}", path.display())), } - Ok(bytes) -} - -fn is_private(metadata: &std::fs::Metadata) -> bool { - use std::os::unix::fs::MetadataExt; - - owned_by_this_user(metadata) && metadata.mode() & 0o077 == 0 -} - -fn owned_by_this_user(metadata: &std::fs::Metadata) -> bool { - use std::os::unix::fs::MetadataExt; - - // SAFETY: geteuid has no preconditions and cannot fail. - metadata.uid() == unsafe { libc::geteuid() } } #[cfg(test)] diff --git a/src/daemon/protocol_v2/session_target/tests.rs b/src/daemon/protocol_v2/session_target/tests.rs index 5275201d..bc7740ed 100644 --- a/src/daemon/protocol_v2/session_target/tests.rs +++ b/src/daemon/protocol_v2/session_target/tests.rs @@ -87,7 +87,7 @@ fn write_entry(name: &str, bytes: &[u8], mode: u32) -> PathBuf { // An overlay's identity proof creates this before any report is written. std::fs::create_dir_all(crate::paths::daemon_command_dir()).unwrap(); - create_report_dir().unwrap(); + super::super::linux::create_private_directory(&report_dir()).unwrap(); let path = report_dir().join(name); std::fs::write(&path, bytes).unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); @@ -135,6 +135,7 @@ fn an_untrusted_report_is_ignored_and_still_removed() { let other = super::super::ProtocolId::generate().unwrap().to_string(); let foreign = record_bytes(&other, pid, ticks, None); let relative = record_bytes(generation, pid, ticks, Some("b.wayscriber-session")); + let directory = record_bytes(generation, pid, ticks, Some("/sessions/b/")); let mut newer_schema = String::from_utf8(record_bytes(generation, pid, ticks, None)).unwrap(); newer_schema = newer_schema.replace(&format!("\"schema\":{REPORT_SCHEMA}"), "\"schema\":2"); @@ -145,6 +146,7 @@ fn an_untrusted_report_is_ignored_and_still_removed() { ("malformed", b"{".as_slice(), 0o600), ("another generation's record", &foreign, 0o600), ("relative target", &relative, 0o600), + ("a directory as target", &directory, 0o600), ("newer schema", newer_schema.as_bytes(), 0o600), ("oversize", &oversize, 0o600), ("readable by others", &valid, 0o644), @@ -215,3 +217,21 @@ fn startup_removes_every_stale_report_and_nothing_else() { assert!(unrelated.iter().all(|path| path.exists())); }); } + +#[test] +fn reports_are_never_read_or_removed_through_a_symlinked_directory() { + as_daemon_overlay(None, |generation| { + let (pid, ticks) = current_identity(); + let elsewhere = crate::test_temp::tempdir().unwrap(); + let report = elsewhere.path().join(format!("{generation}.target")); + std::fs::write(&report, record_bytes(generation, pid, ticks, None)).unwrap(); + std::fs::create_dir_all(crate::paths::daemon_command_dir()).unwrap(); + std::os::unix::fs::symlink(elsewhere.path(), report_dir()).unwrap(); + + assert!(read_session_report(generation, pid, ticks).is_err()); + assert!(clear_stale_session_reports().is_err()); + discard_session_report(generation); + + assert!(report.exists()); + }); +} From e3c20f856ea0ce42e0ba8cd12197b7158520c4a2 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:34:05 +0200 Subject: [PATCH 10/14] docs: describe session reports and keep the session list whole The hide-and-show paragraph in CONFIG.md had landed inside the list of session CLI helpers, cutting the last helper off from the list, and its unquoted was eaten as an HTML tag. It now follows the list, quotes the button names as code, mentions a remembered file replaced by something other than a regular file, and states the --no-resume-session decision: a daemon started with it still continues a named session the overlay switched to, since a named session file always persists, while its default home stays unsaved. The v2 protocol doc now describes the optional launch variables, the overlay-targets/ reports, when the daemon reads them, and why leftovers do not reach an older daemon. The codebase overview credits the report writer and reader to the files that hold them. The no-Python guard reads the first line of every file for a Python shebang, including file types it otherwise checks only by name; its header and the tools README now say so. --- docs/CONFIG.md | 5 +++-- docs/codebase-overview.md | 3 ++- docs/daemon-protocol-v2.md | 20 ++++++++++++++++++++ tests/repository_guards/no_python.rs | 7 ++++--- tools/README.md | 2 +- 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 727cece2..f9c71c55 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -2114,9 +2114,10 @@ Use the CLI helpers for quick maintenance: - `wayscriber --daemon --session-file ~/Documents/lecture-04.wayscriber-session` starts a daemon whose overlay activations use that named session target, the daemon's home session. - `wayscriber --daemon-toggle --session-file ~/Documents/meeting.wayscriber-session` asks the running daemon to launch a hidden overlay with that named session target. It applies to that activation only. If the overlay is already visible with a different target, hide it before switching. -The daemon's overlay keeps its session across hide and show. After **Open** or **Save As** switches the overlay to another session, the next activation continues in that session, until the overlay returns to the daemon's home session: the file given with `--daemon --session-file`, or the configured default session without one. The Session popover's **Back to ** or **Default session** button returns home. A `--daemon-toggle --session-file` request takes precedence for its activation. If the remembered file was moved or deleted, the overlay opens the home session instead and says so. The daemon keeps this only while it runs; a restarted daemon starts at home. - `wayscriber --session-info --session-file `, `wayscriber --clear-session --session-file `, and `wayscriber --clear-tool-state --session-file ` target only that named file. +The daemon's overlay keeps its session across hide and show. After **Open** or **Save As** switches the overlay to another session, the next activation continues in that session, until the overlay returns to the daemon's home session: the file given with `--daemon --session-file`, or the configured default session without one. The Session popover's `Back to ` or `Default session` button returns home. A `--daemon-toggle --session-file` request takes precedence for its activation. If the remembered file was moved, deleted, or replaced by something other than a regular file, the overlay opens the home session instead and says so. The daemon keeps this only while it runs; a restarted daemon starts at home. A daemon started with `--no-resume-session` still continues a named session the overlay switched to, since a named session file always persists; its default home stays unsaved. + Config values seed startup defaults. When `restore_tool_state = true`, the saved session tool state is applied after those defaults, so edits such as `[arrow] head_at_end = true` can appear ignored if the session snapshot still stores an older arrow setting. Run `wayscriber --clear-tool-state` (or add `--session-file ` for a named session) to make config defaults apply on the next startup without deleting saved boards. In a running overlay, Command Palette -> Reset Tool Defaults clears the saved layer for the active session and immediately applies config defaults to the current tools so the next autosave keeps those defaults. The configurator Session tab exposes the same distinction for recent named sessions: Clear Tool State preserves saved boards/history while removing only persisted tool settings; Clear Saved Data removes saved session files. Offline catalog actions are disabled while an overlay, manually started daemon, or background service is active. Use the command palette for the active overlay session. @@ -2127,7 +2128,7 @@ The overlay Session panel lives in the top strip's overflow **"Session..."** pop - `Save As` writes the current overlay to another named session and switches the active target. It appends `.wayscriber-session` when no extension is supplied and asks before replacing existing session artifacts. - `Info` reports the active session file size, board shape counts, and history status. - `Clear` writes a durable empty session boundary for the active target. -- `Back to ` returns to the home session: the file the overlay or daemon started with. Without one it reads `Default session` and returns to the configured default session. Like `Open`, it saves dirty current data first and switches only once home has loaded. It is disabled while home is already active. Hide it with the `side.session.home` toolbar item. +- `Back to ` returns to the home session: the file the overlay or daemon started with. Without one it reads `Default session` and returns to the configured default session. Like `Open`, it saves dirty current data first and switches only once home has loaded. It is disabled while home is already active, and absent while no persisted session is active, since the overlay cannot leave home then. Hide it with the `side.session.home` toolbar item. - Recent session rows reopen other named sessions. If a recent target is missing, Wayscriber removes that stale catalog entry after the failed open. - `Manager` opens the configurator. Overlay Open/Save As dialogs use `zenity` or `kdialog`. diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 2ff908cf..ae017475 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -540,7 +540,8 @@ capture suppression operates on the paired resources without runtime pairing che - `src/session/`: target options, primary-file validation, snapshot load/save, sidecars, clear/recovery markers, saved tool-state reset, locks, catalog metadata, and inactive file operations. - `src/backend/wayland/session/`: runtime Open, Save As, Clear, and saved tool-state reset transactions for the active overlay. - `src/backend/wayland/state/toolbar/events/session.rs`: overlay Session popover routing for Open, Save As, return home, Info, Clear, recent sessions, and configurator launch. -- `src/backend/wayland/session/home.rs`: the overlay's home session, the remembered session a daemon launch carries, and the session reports sent back to the daemon. +- `src/backend/wayland/session/home.rs`: the overlay's home session, the remembered session a daemon launch carries, and loading a remembered session or home in its place. `src/backend/wayland/state/core/session_home.rs` reports the overlay's session to the daemon. +- `src/daemon/protocol_v2/session_target.rs`: writes and reads the per-generation session reports in `daemon-commands/overlay-targets/`. - `src/daemon/`: accepts daemon-toggle requests that carry an optional named session target, and remembers the session its overlay last reported across hide and show. **Flow:** diff --git a/docs/daemon-protocol-v2.md b/docs/daemon-protocol-v2.md index 8995b659..00ca7316 100644 --- a/docs/daemon-protocol-v2.md +++ b/docs/daemon-protocol-v2.md @@ -69,6 +69,24 @@ About clipboard integration, and named test fixtures. The same check audits the stub: before `execve` it may reach only the fixed `fcntl`, `dup3`, `setpgid`, `close_range`, `execve`, and `exit_group` syscall set over prebuilt buffers. +## Overlay session reports + +A daemon launch passes three optional environment variables, so an older overlay ignores them: +`WAYSCRIBER_OVERLAY_SESSION_REPORTS=1` says the daemon reads session reports, +`WAYSCRIBER_OVERLAY_HOME_SESSION` names its startup session file, and +`WAYSCRIBER_OVERLAY_PREFERRED_SESSION` names the session it remembers, omitted when the request +carried its own `--session-file`. The command line is unchanged. The broker strips these variables +from helpers that do not relaunch wayscriber. + +An overlay with a published child identity reports its session in +`daemon-commands/overlay-targets/.target`, a private sibling of `v2/`, never inside +it. The canonical JSON record carries a schema version, the generation, PID and process-start +identity, and a target: an absolute session file, or null for home. It is replaced on each change. +When the child is retired, on exit, stop or forced reap, the daemon reads the report before it +releases the child's identity, accepts it only from a private regular file in a private directory +with exactly the identity captured at readiness, and removes it either way. The remembered session +lives only in daemon memory; startup removes reports an earlier daemon left without restoring them. + ## Compatibility and rollback - A v2 client against a v1 daemon uses the strict legacy parser and v1 request path. @@ -76,6 +94,8 @@ stub: before `execve` it may reach only the fixed `fcntl`, `dup3`, `setpgid`, `c explicitly empty visibility signal remains supported. - V1 cleanup removes only exact v1 request/response artifacts and never recursively removes the v2 root. +- Session reports sit outside the strict v2 tree, and the legacy request scan reads only plain + files in `daemon-commands/`, so reports left behind never reach an older daemon's parsers. - Restart recovery rejects prior-generation open commands with a durable no-effect response and records authorized commands without terminal proof as indeterminate. Foreign-generation journal entries are abandoned rather than replayed. diff --git a/tests/repository_guards/no_python.rs b/tests/repository_guards/no_python.rs index 6c907619..ed6b3ce8 100644 --- a/tests/repository_guards/no_python.rs +++ b/tests/repository_guards/no_python.rs @@ -26,9 +26,10 @@ //! `github.com/python/cpython`, is reported, so such a link is reworded; //! an untracked directory that is not ignored and holds no `CACHEDIR.TAG`, such //! as `.direnv/` or `node_modules/`, is read like a source directory; other file -//! types, such as `.csproj`, `.json`, `.spec`, or `.install`, are checked by name -//! only; and a file that `.gitignore` re-includes only through a `!` line with a -//! glob or a directory, such as `!packaging/*.sh`, stays skipped. +//! types, such as `.csproj`, `.json`, `.spec`, or `.install`, are checked only by +//! name and by their first line for a Python shebang; and a file that +//! `.gitignore` re-includes only through a `!` line with a glob or a directory, +//! such as `!packaging/*.sh`, stays skipped. use std::collections::BTreeMap; use std::fs; diff --git a/tools/README.md b/tools/README.md index 799b73b4..fb1764c5 100644 --- a/tools/README.md +++ b/tools/README.md @@ -89,7 +89,7 @@ shell version. Run `--help` for the complete command list and options. - `config_writers.rs` rejects `config.toml` write capability outside the configurator's Save and the overlay's pinned narrow editors - `process_sites.rs` keeps process creation inside the process broker and audits the broker's post-fork child stub - `shared_dependencies.rs` keeps the shared domain and config validation layers free of upward crate paths, using the syntax corpus beside it - - `no_python.rs` rejects Python files and links to them, Python project and lock files, Python shebangs, and Python interpreter or package names in the `.rs`, `.cs`, `.props`, `.targets`, `.sh`, `.nix`, `.toml`, YAML, `.service`, `.desktop`, and extensionless files it reads; other files are checked by name, and its known limits are listed at the top of the file + - `no_python.rs` rejects Python files and links to them, Python project and lock files, Python shebangs, and Python interpreter or package names in the `.rs`, `.cs`, `.props`, `.targets`, `.sh`, `.nix`, `.toml`, YAML, `.service`, `.desktop`, and extensionless files it reads; other files are checked by name and for a Python shebang, and its known limits are listed at the top of the file - Each guard carries regression fixtures for the escapes it forbids - Usage: `cargo test --test repository_guards` From abd28c9eb1517ae3940896835fce04f98fabc4a3 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:34:05 +0200 Subject: [PATCH 11/14] test: read the launch arguments of helper starts in one place The fake overlay kept its own reader of /proc/self/cmdline beside the one the fake helper constructor uses. It now reuses that reader. The helper's header no longer claims that no test relies on a system program: it is the helpers it plays that need neither a script nor one. --- src/daemon/overlay/tests/fake_overlay.rs | 22 +++++----------------- src/test_fake_helper.rs | 12 +++++++----- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/src/daemon/overlay/tests/fake_overlay.rs b/src/daemon/overlay/tests/fake_overlay.rs index 4417f20d..b3de0432 100644 --- a/src/daemon/overlay/tests/fake_overlay.rs +++ b/src/daemon/overlay/tests/fake_overlay.rs @@ -11,7 +11,6 @@ use std::convert::Infallible; use std::ffi::OsStr; -use std::os::unix::ffi::OsStrExt; use std::path::Path; use std::time::Duration; @@ -172,27 +171,16 @@ fn runtime_root() -> Result { /// Whether this process was started as `name`: the broker passes the program /// path it was given as `argv[0]`, which for a link is the link's own path. fn launched_as(name: &str) -> bool { - nul_separated("/proc/self/cmdline") + crate::test_fake_helper::launch_arguments() .ok() .and_then(|arguments| arguments.into_iter().next()) - .is_some_and(|program| { - Path::new(OsStr::from_bytes(&program)).file_name() == Some(OsStr::new(name)) - }) + .is_some_and(|program| Path::new(&program).file_name() == Some(OsStr::new(name))) } -/// Reads the kernel's copy of argv: std's own argument capture is not -/// guaranteed to have run before this constructor. +/// The overlay arguments this process was launched with, after `argv[0]`. fn launch_arguments() -> Result> { - nul_separated("/proc/self/cmdline")? + Ok(crate::test_fake_helper::launch_arguments()? .into_iter() .skip(1) - .map(|argument| String::from_utf8(argument).context("non-UTF-8 launch argument")) - .collect() -} - -fn nul_separated(path: &str) -> Result>> { - let raw = std::fs::read(path).with_context(|| format!("failed to read {path}"))?; - let raw = raw.strip_suffix(b"\0").unwrap_or(&raw); - - Ok(raw.split(|byte| *byte == 0).map(<[u8]>::to_vec).collect()) + .collect()) } diff --git a/src/test_fake_helper.rs b/src/test_fake_helper.rs index 800acf32..81383699 100644 --- a/src/test_fake_helper.rs +++ b/src/test_fake_helper.rs @@ -4,8 +4,9 @@ //! [`environment`] for that link while it launches the helper. When this binary //! starts under exactly that path, the constructor below plays the requested //! [`Role`] and exits before libtest runs. Any other start, such as another -//! test's child launched while the variables are set, runs the tests as usual, -//! so no test writes a script or relies on a system program. +//! test's child launched while the variables are set, runs the tests as usual. +//! A test that needs a helper program therefore writes no script and relies on +//! no system program for it. use std::ffi::OsStr; use std::io::Read; @@ -146,9 +147,10 @@ fn play(arguments: &[String]) -> Result<()> { Ok(()) } -/// The arguments this process was launched with, `argv[0]` first: std's own -/// argument capture may not have run before this constructor. -fn launch_arguments() -> Result> { +/// The arguments this process was launched with, `argv[0]` first, for a +/// constructor that runs before `main`: std's own argument capture may not have +/// run yet. +pub(crate) fn launch_arguments() -> Result> { let raw = std::fs::read("/proc/self/cmdline").context("read /proc/self/cmdline")?; let raw = raw.strip_suffix(b"\0").unwrap_or(&raw); From 1669ba6b08513a5369fbaa255f76abc1ac4a9fea Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:58:51 +0200 Subject: [PATCH 12/14] fix(session): hold home and retried sessions to files a save can replace Return home checked only a named home before loading it. A configured home whose session file had become a symlink was followed, and one that had become a directory loaded from a recovery copy beside it; either replaced the current canvas and committed home, and every later save failed. Home now loads only while its file is one a save can replace, a regular file or none yet, checked before and after the load. When the first load of a remembered session failed and was retried, the output transition saved the current session before loading, and that session was still the never-loaded remembered one. If its file had been deleted with nothing left beside it, the save recreated it, the load then accepted it, and the overlay continued an empty session at the deleted path; with its folder gone too, every retry failed on the save and never reached home. The transition now checks the remembered session before that save and skips saving one that can no longer be used, so the load gives it up for home. --- src/backend/wayland/session.rs | 2 +- src/backend/wayland/session/driver/tests.rs | 59 +++++++++++++++---- src/backend/wayland/session/home.rs | 30 +++++++++- src/backend/wayland/session/home/tests.rs | 28 +++++++++ src/backend/wayland/session/persistence.rs | 5 ++ .../wayland/session/persistence/worker.rs | 58 +++++++++++++++--- .../wayland/state/core/output/session_ops.rs | 30 ++++++++-- .../wayland/state/core/output/transition.rs | 4 +- 8 files changed, 186 insertions(+), 30 deletions(-) diff --git a/src/backend/wayland/session.rs b/src/backend/wayland/session.rs index ae233b72..8615f278 100644 --- a/src/backend/wayland/session.rs +++ b/src/backend/wayland/session.rs @@ -609,7 +609,7 @@ mod runtime; #[cfg(test)] pub(in crate::backend::wayland) use home::HomeSession; pub(in crate::backend::wayland) use home::{ - SessionHome, SessionLaunch, load_output_session, session_target, + SessionHome, SessionLaunch, load_output_session, may_save_before_output_load, session_target, }; pub(in crate::backend::wayland) use load_outcome::{ ExpandedTooLarge, apply_load_outcome, replace_output_session_snapshot, diff --git a/src/backend/wayland/session/driver/tests.rs b/src/backend/wayland/session/driver/tests.rs index 5682849d..2667f323 100644 --- a/src/backend/wayland/session/driver/tests.rs +++ b/src/backend/wayland/session/driver/tests.rs @@ -761,17 +761,54 @@ fn returning_home_saves_the_current_session_then_loads_home_like_a_launch() { ); } +/// A home whose session file a save could not replace, of the kind `case` +/// names, and the error that names it. +fn unsaveable_home( + base: &std::path::Path, + case: &str, +) -> (stored_session::SessionOptions, &'static str) { + let configured = configured_home(base); + let saved = named_options(base, "saved"); + stored_session::save_snapshot(&sample_snapshot(), &saved).unwrap(); + match case { + // A launch would start on an empty canvas here. + "configured directory" => { + std::fs::create_dir_all(configured.session_file_path()).unwrap(); + (configured, "is a directory") + } + // A launch would restore the recovery copy, then fail every save. + "configured directory beside a recovery copy" => { + std::fs::create_dir_all(configured.session_file_path()).unwrap(); + std::fs::copy(saved.session_file_path(), configured.recovery_file_path()).unwrap(); + (configured, "is a directory") + } + // A launch would follow the link, then fail every save. + "configured symlink" => { + std::fs::create_dir_all(&configured.base_dir).unwrap(); + std::os::unix::fs::symlink(saved.session_file_path(), configured.session_file_path()) + .unwrap(); + (configured, "is a symlink") + } + "named directory" => { + let named = named_options(base, "named-home"); + std::fs::create_dir(named.session_file_path()).unwrap(); + (named, "directory") + } + _ => unreachable!(), + } +} + #[test] fn a_home_that_cannot_be_loaded_leaves_the_current_session() { - let temp = crate::test_temp::tempdir().unwrap(); - let current = named_options(temp.path(), "current"); - let configured = configured_home(temp.path()); - // A launch would start empty on these; a return home refuses them. - std::fs::create_dir_all(configured.session_file_path()).unwrap(); - let named = named_options(temp.path(), "named-home"); - std::fs::create_dir(named.session_file_path()).unwrap(); - - for (home, expected) in [(configured, "not a regular file"), (named, "directory")] { + for case in [ + "configured directory", + "configured directory beside a recovery copy", + "configured symlink", + "named directory", + ] { + let temp = crate::test_temp::tempdir().unwrap(); + let current = named_options(temp.path(), "current"); + let (home, expected) = unsaveable_home(temp.path(), case); let mut input = test_input_state(); add_line(&mut input, 51); input.mark_session_dirty(); @@ -790,12 +827,12 @@ fn a_home_that_cannot_be_loaded_leaves_the_current_session() { assert!(runtime.reports.is_empty()); assert!( format!("{:#}", runtime.errors[0]).contains(expected), - "{:?}", + "{case}: {:?}", runtime.errors ); assert_eq!(runtime.session.options().unwrap().target, current.target); let shapes = &runtime.input.boards.active_frame().shapes; - assert_eq!(shapes.len(), 1, "{expected}"); + assert_eq!(shapes.len(), 1, "{case}"); assert!(matches!( shapes[0].shape, crate::draw::Shape::Line { x2: 51, .. } diff --git a/src/backend/wayland/session/home.rs b/src/backend/wayland/session/home.rs index bad4a05b..0fbfe314 100644 --- a/src/backend/wayland/session/home.rs +++ b/src/backend/wayland/session/home.rs @@ -204,6 +204,32 @@ pub(in crate::backend::wayland) fn session_target( options.map_or(SessionTarget::Configured, |options| options.target.clone()) } +fn is_remembered(options: &SessionOptions, remembered: Option<&Path>) -> bool { + remembered.is_some_and(|path| options.target == SessionTarget::NamedFile(path.into())) +} + +/// Whether `current` may be saved before an output's session loads. A +/// remembered session that has not loaded and can no longer be used may not: +/// the save would recreate it, and the load would then continue it in place +/// of home. +pub(in crate::backend::wayland) fn may_save_before_output_load( + current: &SessionOptions, + remembered: Option<&Path>, + mut run: impl FnMut(PersistenceOperation) -> Result, +) -> Result { + if !is_remembered(current, remembered) { + return Ok(true); + } + + match run(PersistenceOperation::CheckRemembered { + path: current.session_file_path(), + })? { + PersistenceOutcome::Unit => Ok(true), + PersistenceOutcome::RememberedUnavailable(_) => Ok(false), + other => bail!("unexpected remembered session check outcome: {other:?}"), + } +} + /// What an output's session load found. #[derive(Debug)] pub(in crate::backend::wayland) struct OutputSessionLoad { @@ -224,9 +250,7 @@ pub(in crate::backend::wayland) fn load_output_session( home: Option, mut run: impl FnMut(PersistenceOperation) -> Result, ) -> Result { - let continues_remembered = - remembered.is_some_and(|path| staged.target == SessionTarget::NamedFile(path.into())); - let operation = if continues_remembered { + let operation = if is_remembered(&staged, remembered) { PersistenceOperation::LoadRemembered { options: staged.clone(), } diff --git a/src/backend/wayland/session/home/tests.rs b/src/backend/wayland/session/home/tests.rs index 9f0f4a75..6a47f7ad 100644 --- a/src/backend/wayland/session/home/tests.rs +++ b/src/backend/wayland/session/home/tests.rs @@ -362,11 +362,39 @@ mod load { assert!(failed.is_err()); std::fs::remove_file(&path).unwrap(); + // The retry first saves the current session, as an output transition + // does; a save into the remembered session would recreate it. + let remembered = sessions.remembered.clone(); + let may_save = may_save_before_output_load(&remembered, Some(&path), |operation| { + sessions.persistence.run(0, operation) + }) + .unwrap(); + if may_save { + stored_session::save_snapshot(&sample_snapshot(), &remembered).unwrap(); + } let load = sessions.load(Some(home)).unwrap(); + assert!(!may_save); sessions.assert_went_home(load); } + #[test] + fn only_an_unusable_remembered_session_holds_back_the_save() { + let mut sessions = sessions(); + let path = sessions.remembered.session_file_path(); + let remembered = sessions.remembered.clone(); + let mut run = |operation| sessions.persistence.run(0, operation); + + assert!(may_save_before_output_load(&remembered, Some(&path), &mut run).unwrap()); + // Another session is saved as before, without a check. + assert!( + may_save_before_output_load(&remembered, None, |_| { + panic!("no check runs for a session that is not remembered") + }) + .unwrap() + ); + } + #[test] fn without_persistence_home_has_nothing_to_load() { let mut sessions = sessions(); diff --git a/src/backend/wayland/session/persistence.rs b/src/backend/wayland/session/persistence.rs index fcebc02d..21342f29 100644 --- a/src/backend/wayland/session/persistence.rs +++ b/src/backend/wayland/session/persistence.rs @@ -57,6 +57,10 @@ pub(in crate::backend::wayland) enum PersistenceOperation { LoadHome { options: SessionOptions, }, + /// Whether a remembered session file is still usable, without loading it. + CheckRemembered { + path: PathBuf, + }, /// Loads a remembered session file only while it is still a usable /// session file, before and after it loads. LoadRemembered { @@ -105,6 +109,7 @@ impl PersistenceOperation { Self::LoadNamedCandidate { .. } => "load-named-candidate", Self::LoadHome { .. } => "load-home", Self::LoadRemembered { .. } => "load-remembered", + Self::CheckRemembered { .. } => "check-remembered", Self::Inspect { .. } => "inspect", Self::SaveAsOverwritePreflight { .. } => "save-as-overwrite-preflight", Self::ValidateNamedOpen { .. } => "validate-named-open", diff --git a/src/backend/wayland/session/persistence/worker.rs b/src/backend/wayland/session/persistence/worker.rs index 7978e50b..102c7807 100644 --- a/src/backend/wayland/session/persistence/worker.rs +++ b/src/backend/wayland/session/persistence/worker.rs @@ -134,15 +134,14 @@ pub(super) fn execute(operation: PersistenceOperation) -> Result Ok(PersistenceOutcome::Load( session::load_named_session_candidate(&options)?, )), - PersistenceOperation::LoadHome { options } => { - if options.is_named_file() { - session::validate_named_session_file_for_foreground(&options.session_file_path())?; - } - Ok(PersistenceOutcome::Load( - session::load_snapshot_with_outcome(&options)?, - )) - } + PersistenceOperation::LoadHome { options } => load_home(&options), PersistenceOperation::LoadRemembered { options } => load_remembered(&options), + PersistenceOperation::CheckRemembered { path } => { + Ok(match session::validate_named_session_file_for_open(&path) { + Ok(()) => PersistenceOutcome::Unit, + Err(error) => PersistenceOutcome::RememberedUnavailable(error), + }) + } PersistenceOperation::Inspect { options } => Ok(PersistenceOutcome::Inspection( session::inspect_session(&options)?, )), @@ -188,6 +187,49 @@ pub(super) fn execute(operation: PersistenceOperation) -> Result Result { + let path = options.session_file_path(); + let check = || { + if options.is_named_file() { + session::validate_named_session_file_for_foreground(&path) + } else { + require_replaceable_session_file(&path) + } + }; + + check()?; + let outcome = session::load_snapshot_with_outcome(options)?; + check()?; + Ok(PersistenceOutcome::Load(outcome)) +} + +/// A session file that a save can replace: a regular file, or none yet. +fn require_replaceable_session_file(path: &Path) -> Result<()> { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(error) + .with_context(|| format!("failed to inspect session file {}", path.display())); + } + }; + let kind = if metadata.is_file() { + return Ok(()); + } else if metadata.file_type().is_symlink() { + "a symlink" + } else if metadata.is_dir() { + "a directory" + } else { + "not a regular file" + }; + Err(anyhow!("session file {} is {kind}", path.display())) +} + /// Loads a remembered session with the startup rules of a session file, but /// only from that file itself. A file moved or deleted since it was remembered /// is not continued from a backup or recovery copy left beside it, and neither diff --git a/src/backend/wayland/state/core/output/session_ops.rs b/src/backend/wayland/state/core/output/session_ops.rs index d56b11bb..fa4c25eb 100644 --- a/src/backend/wayland/state/core/output/session_ops.rs +++ b/src/backend/wayland/state/core/output/session_ops.rs @@ -1,5 +1,7 @@ use super::*; -use crate::backend::wayland::session::{ExpandedTooLarge, apply_load_outcome, load_output_session}; +use crate::backend::wayland::session::{ + ExpandedTooLarge, apply_load_outcome, load_output_session, may_save_before_output_load, +}; impl WaylandState { /// Loads and commits `staged` as the session for the output identified as @@ -13,11 +15,7 @@ impl WaylandState { physical_output_identity: Option<&str>, context: &str, ) -> anyhow::Result<()> { - let remembered = self - .session_home - .remembered() - .filter(|_| !self.session.is_loaded()) - .map(std::path::Path::to_path_buf); + let remembered = self.unloaded_remembered_session(); let home = self .session_home .options_for_output(physical_output_identity); @@ -50,6 +48,26 @@ impl WaylandState { Ok(()) } + /// The remembered session this run continues, while no session has loaded. + fn unloaded_remembered_session(&self) -> Option { + self.session_home + .remembered() + .filter(|_| !self.session.is_loaded()) + .map(std::path::Path::to_path_buf) + } + + /// Whether `current` may be saved before an output's session loads: see + /// [`may_save_before_output_load`]. + pub(super) fn may_save_before_output_load( + &mut self, + current: &session::SessionOptions, + ) -> anyhow::Result { + let remembered = self.unloaded_remembered_session(); + may_save_before_output_load(current, remembered.as_deref(), |operation| { + session_save::run_persistence_operation(self, operation) + }) + } + /// After a launch-time session load, announce ink restored onto the /// transparent overlay board, once per launch. With per-output sessions /// the ink arrives with the first output transition rather than the diff --git a/src/backend/wayland/state/core/output/transition.rs b/src/backend/wayland/state/core/output/transition.rs index f317f28b..e7253f6e 100644 --- a/src/backend/wayland/state/core/output/transition.rs +++ b/src/backend/wayland/state/core/output/transition.rs @@ -226,7 +226,9 @@ impl WaylandState { .session_options() .cloned() .ok_or_else(|| anyhow::anyhow!("output transition has no active session options"))?; - self.persist_current_session_for_transition(¤t_options, reason)?; + if self.may_save_before_output_load(¤t_options)? { + self.persist_current_session_for_transition(¤t_options, reason)?; + } self.load_configured_session_for_options( staged_options, From f4adda54dbd476d243398569b6fbf402a0f58b6d Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:02:49 +0200 Subject: [PATCH 13/14] refactor(session): tighten the continuity code from the re-review - An output's session load is now an enum, Loaded or WentHome, so the impossible state of neither a loaded session nor a reason is gone. - The daemon checks only the shape of a reported session path. A file that changes before the next show is the overlay's to judge when it continues it; checking it at retirement kept a stale remembered session. - The report file name is built in one place. - The shared private-directory helper again refuses a directory another user owns. - launched_resume_policy names the environment's own policy apart from resume_override_from_env; both now say how they differ. - Return home asserts that a home too large to load never reaches the canvas, instead of dropping the value. - The fake helper keeps launch arguments as bytes, so an argument that is not UTF-8 cannot stop a helper start from recognising itself. - Imports sit at the top of the action journal and command layout, and docs no longer say a remembered session is continued "if it still exists". --- src/backend/wayland/backend/helpers.rs | 12 +++-- .../wayland/backend/state_init/session.rs | 4 +- src/backend/wayland/session.rs | 3 +- src/backend/wayland/session/home.rs | 52 +++++++++---------- src/backend/wayland/session/home/tests.rs | 30 +++++++---- .../session/runtime/transaction/phases.rs | 3 +- .../wayland/state/core/output/session_ops.rs | 52 +++++++++++++------ src/daemon/overlay/tests/fake_overlay.rs | 9 +++- src/daemon/protocol_v2/action.rs | 3 +- src/daemon/protocol_v2/command/layout.rs | 3 +- src/daemon/protocol_v2/linux.rs | 7 ++- src/daemon/protocol_v2/session_target.rs | 18 +++++-- src/env_vars.rs | 4 +- src/test_fake_helper.rs | 29 ++++++----- 14 files changed, 139 insertions(+), 90 deletions(-) diff --git a/src/backend/wayland/backend/helpers.rs b/src/backend/wayland/backend/helpers.rs index ace2d629..88b6016c 100644 --- a/src/backend/wayland/backend/helpers.rs +++ b/src/backend/wayland/backend/helpers.rs @@ -284,13 +284,17 @@ pub(super) fn dispatch_with_timeout( dispatch_runtime_cycle(&mut ops, timeout) } +/// The resume override this run follows: its own, such as the one a named +/// session file forces on, else the policy the launch environment passed. pub(super) fn resume_override_from_env() -> Option { - runtime_session_override().or_else(resume_override_from_env_var) + runtime_session_override().or_else(launched_resume_policy) } -/// The resume policy the launch environment passed, without the override -/// this run applies for itself, such as the one a named session file forces. -pub(super) fn resume_override_from_env_var() -> Option { +/// The resume policy the launch environment passed in +/// `WAYSCRIBER_RESUME_SESSION`. Unlike [`resume_override_from_env`], it leaves +/// out the override this run applies for itself, such as the one a named +/// session file forces on. +pub(super) fn launched_resume_policy() -> Option { match env::var(RESUME_SESSION_ENV) { Ok(raw) => { let normalized = raw.trim().to_ascii_lowercase(); diff --git a/src/backend/wayland/backend/state_init/session.rs b/src/backend/wayland/backend/state_init/session.rs index 3b885a2f..b10f9621 100644 --- a/src/backend/wayland/backend/state_init/session.rs +++ b/src/backend/wayland/backend/state_init/session.rs @@ -7,7 +7,7 @@ use crate::config::Config; use crate::env_vars::WAYLAND_DISPLAY_ENV; use crate::{RESUME_SESSION_ENV, paths, session}; -use super::super::helpers::{resume_override_from_env, resume_override_from_env_var}; +use super::super::helpers::{launched_resume_policy, resume_override_from_env}; /// The options this run starts with, logged. pub(super) fn build_session_options( @@ -34,7 +34,7 @@ pub(super) fn home_session_options( launch: &SessionLaunch, ) -> Option { let resume_override = if launch.from_daemon { - resume_override_from_env_var() + launched_resume_policy() } else { resume_override_from_env() }; diff --git a/src/backend/wayland/session.rs b/src/backend/wayland/session.rs index 8615f278..fe3cf229 100644 --- a/src/backend/wayland/session.rs +++ b/src/backend/wayland/session.rs @@ -609,7 +609,8 @@ mod runtime; #[cfg(test)] pub(in crate::backend::wayland) use home::HomeSession; pub(in crate::backend::wayland) use home::{ - SessionHome, SessionLaunch, load_output_session, may_save_before_output_load, session_target, + OutputSessionLoad, SessionHome, SessionLaunch, load_output_session, + may_save_before_output_load, session_target, }; pub(in crate::backend::wayland) use load_outcome::{ ExpandedTooLarge, apply_load_outcome, replace_output_session_snapshot, diff --git a/src/backend/wayland/session/home.rs b/src/backend/wayland/session/home.rs index 0fbfe314..b32215f0 100644 --- a/src/backend/wayland/session/home.rs +++ b/src/backend/wayland/session/home.rs @@ -42,7 +42,8 @@ impl HomeSession { #[derive(Debug, Clone, PartialEq, Eq)] pub(in crate::backend::wayland) struct SessionLaunch { pub(in crate::backend::wayland) home: HomeSession, - /// A remembered session to start in instead of home, if it still exists. + /// A remembered session to start in instead of home, while it is still a + /// usable session file. pub(in crate::backend::wayland) preferred: Option, /// Whether a daemon that reads reports launched the overlay, so its /// resume policy is the one the daemon passed. @@ -232,13 +233,17 @@ pub(in crate::backend::wayland) fn may_save_before_output_load( /// What an output's session load found. #[derive(Debug)] -pub(in crate::backend::wayland) struct OutputSessionLoad { - /// The options loaded and what loading them found: the staged options, or - /// home's in place of a remembered session that can no longer be used. - /// `None` when that home has persistence disabled, leaving nothing to load. - pub(in crate::backend::wayland) loaded: Option<(SessionOptions, LoadSnapshotOutcome)>, - /// The remembered session given up for home, and why. - pub(in crate::backend::wayland) abandoned: Option<(PathBuf, anyhow::Error)>, +pub(in crate::backend::wayland) enum OutputSessionLoad { + /// The staged session loaded. + Loaded(SessionOptions, LoadSnapshotOutcome), + /// A remembered session that can no longer be used was given up for home. + WentHome { + remembered: PathBuf, + reason: anyhow::Error, + /// Home's options and what loading them found; `None` when home has + /// persistence disabled, leaving nothing to load. + home: Option<(SessionOptions, LoadSnapshotOutcome)>, + }, } /// Loads `staged`, the session an output starts in, through `run`. When it is @@ -250,27 +255,20 @@ pub(in crate::backend::wayland) fn load_output_session( home: Option, mut run: impl FnMut(PersistenceOperation) -> Result, ) -> Result { + let options = staged.clone(); let operation = if is_remembered(&staged, remembered) { - PersistenceOperation::LoadRemembered { - options: staged.clone(), - } + PersistenceOperation::LoadRemembered { options } } else { - PersistenceOperation::LoadConfigured { - options: staged.clone(), - } + PersistenceOperation::LoadConfigured { options } }; - let abandoned = match run(operation)? { - PersistenceOutcome::Load(outcome) => { - return Ok(OutputSessionLoad { - loaded: Some((staged, outcome)), - abandoned: None, - }); - } - PersistenceOutcome::RememberedUnavailable(error) => (staged.session_file_path(), error), + + let reason = match run(operation)? { + PersistenceOutcome::Load(outcome) => return Ok(OutputSessionLoad::Loaded(staged, outcome)), + PersistenceOutcome::RememberedUnavailable(reason) => reason, other => bail!("unexpected session load outcome: {other:?}"), }; - let loaded = match home { + let home = match home { Some(home) => match run(PersistenceOperation::LoadConfigured { options: home.clone(), })? { @@ -279,10 +277,10 @@ pub(in crate::backend::wayland) fn load_output_session( }, None => None, }; - - Ok(OutputSessionLoad { - loaded, - abandoned: Some(abandoned), + Ok(OutputSessionLoad::WentHome { + remembered: staged.session_file_path(), + reason, + home, }) } diff --git a/src/backend/wayland/session/home/tests.rs b/src/backend/wayland/session/home/tests.rs index 6a47f7ad..d8024c41 100644 --- a/src/backend/wayland/session/home/tests.rs +++ b/src/backend/wayland/session/home/tests.rs @@ -265,12 +265,18 @@ mod load { } fn assert_went_home(&mut self, load: OutputSessionLoad) -> anyhow::Error { - let (options, outcome) = load.loaded.expect("home loads"); + let OutputSessionLoad::WentHome { + remembered, + reason, + home: Some((options, outcome)), + } = load + else { + panic!("expected home to load in place of the remembered session: {load:?}"); + }; assert_eq!(options.target, self.home.target); assert!(matches!(outcome, LoadSnapshotOutcome::Loaded(_))); - let (path, error) = load.abandoned.expect("the remembered session is given up"); - assert_eq!(path, self.remembered.session_file_path()); - error + assert_eq!(remembered, self.remembered.session_file_path()); + reason } } @@ -281,10 +287,11 @@ mod load { let load = sessions.load(Some(home)).unwrap(); - let (options, outcome) = load.loaded.unwrap(); + let OutputSessionLoad::Loaded(options, outcome) = load else { + panic!("expected the remembered session to load: {load:?}"); + }; assert_eq!(options.target, sessions.remembered.target); assert!(matches!(outcome, LoadSnapshotOutcome::Loaded(_))); - assert!(load.abandoned.is_none()); } #[test] @@ -402,8 +409,10 @@ mod load { let load = sessions.load(None).unwrap(); - assert!(load.loaded.is_none()); - assert!(load.abandoned.is_some()); + assert!(matches!( + load, + OutputSessionLoad::WentHome { home: None, .. } + )); } #[test] @@ -419,9 +428,10 @@ mod load { }) .unwrap(); - let (options, outcome) = load.loaded.unwrap(); + let OutputSessionLoad::Loaded(options, outcome) = load else { + panic!("expected the startup session to load: {load:?}"); + }; assert_eq!(options.target, remembered.target); assert!(matches!(outcome, LoadSnapshotOutcome::Empty)); - assert!(load.abandoned.is_none()); } } diff --git a/src/backend/wayland/session/runtime/transaction/phases.rs b/src/backend/wayland/session/runtime/transaction/phases.rs index fb190eb9..25356475 100644 --- a/src/backend/wayland/session/runtime/transaction/phases.rs +++ b/src/backend/wayland/session/runtime/transaction/phases.rs @@ -207,13 +207,14 @@ impl ExplicitSessionTransaction { } let loaded_board_data = load.has_board_data(); - apply_load_outcome( + let too_large = apply_load_outcome( context.input_state, context.measurer, load, &options, "home session", )?; + debug_assert!(too_large.is_none(), "a home too large to load was refused"); context .input_state .set_session_preflight_options(Some(options.clone())); diff --git a/src/backend/wayland/state/core/output/session_ops.rs b/src/backend/wayland/state/core/output/session_ops.rs index fa4c25eb..b6183653 100644 --- a/src/backend/wayland/state/core/output/session_ops.rs +++ b/src/backend/wayland/state/core/output/session_ops.rs @@ -1,6 +1,7 @@ use super::*; use crate::backend::wayland::session::{ - ExpandedTooLarge, apply_load_outcome, load_output_session, may_save_before_output_load, + ExpandedTooLarge, OutputSessionLoad, apply_load_outcome, load_output_session, + may_save_before_output_load, }; impl WaylandState { @@ -24,30 +25,47 @@ impl WaylandState { session_save::run_persistence_operation(self, operation) })?; - match load.loaded { - Some((options, outcome)) => { - let loaded_board_data = outcome.has_board_data(); - self.handle_session_load_outcome_for_options(outcome, &options, context)?; - if load.abandoned.is_some() { - self.input_state - .set_session_preflight_options(Some(options.clone())); - } - self.session - .commit_output_options(options, loaded_board_data); + match load { + OutputSessionLoad::Loaded(options, outcome) => { + self.commit_output_session(options, outcome, context)?; } - None => { - self.session.replace_options_before_load(None); - self.input_state.set_session_preflight_options(None); + OutputSessionLoad::WentHome { + remembered, + reason, + home, + } => { + match home { + Some((options, outcome)) => { + self.commit_output_session(options.clone(), outcome, context)?; + self.input_state + .set_session_preflight_options(Some(options)); + } + None => { + self.session.replace_options_before_load(None); + self.input_state.set_session_preflight_options(None); + } + } + self.notify_remembered_session_abandoned(&remembered, &reason); } } - if let Some((path, error)) = load.abandoned { - self.notify_remembered_session_abandoned(&path, &error); - } self.report_session_to_daemon(); Ok(()) } + fn commit_output_session( + &mut self, + options: session::SessionOptions, + outcome: session::LoadSnapshotOutcome, + context: &str, + ) -> anyhow::Result<()> { + let loaded_board_data = outcome.has_board_data(); + self.handle_session_load_outcome_for_options(outcome, &options, context)?; + self.session + .commit_output_options(options, loaded_board_data); + Ok(()) + } + /// The remembered session this run continues, while no session has loaded. fn unloaded_remembered_session(&self) -> Option { self.session_home diff --git a/src/daemon/overlay/tests/fake_overlay.rs b/src/daemon/overlay/tests/fake_overlay.rs index b3de0432..5c555c32 100644 --- a/src/daemon/overlay/tests/fake_overlay.rs +++ b/src/daemon/overlay/tests/fake_overlay.rs @@ -179,8 +179,13 @@ fn launched_as(name: &str) -> bool { /// The overlay arguments this process was launched with, after `argv[0]`. fn launch_arguments() -> Result> { - Ok(crate::test_fake_helper::launch_arguments()? + crate::test_fake_helper::launch_arguments()? .into_iter() .skip(1) - .collect()) + .map(|argument| { + argument + .into_string() + .map_err(|_| anyhow::anyhow!("non-UTF-8 launch argument")) + }) + .collect() } diff --git a/src/daemon/protocol_v2/action.rs b/src/daemon/protocol_v2/action.rs index ea3eb711..6e9c4520 100644 --- a/src/daemon/protocol_v2/action.rs +++ b/src/daemon/protocol_v2/action.rs @@ -9,6 +9,7 @@ use anyhow::{Context, Result, anyhow, bail}; use serde::{Deserialize, Serialize}; use super::digest::sha256_hex; +use super::linux::create_private_directory; use super::wire::{ ACTION_ENVELOPE_PROTOCOL_VERSION, MAX_ACTION_ENVELOPE_BYTES, bounded_reason, canonical_json, fresh_id, parse_canonical_json, validate_digest, validate_id, validate_reason, validate_token, @@ -197,8 +198,6 @@ fn action_name(order: u64, identity: &str) -> String { format!("{order:016x}-{identity}.action") } -use super::linux::create_private_directory; - fn open_journal_lock(root: &Path) -> Result { try_open_journal_lock(root, false)? .ok_or_else(|| anyhow!("blocking action journal lock unexpectedly deferred")) diff --git a/src/daemon/protocol_v2/command/layout.rs b/src/daemon/protocol_v2/command/layout.rs index 4e9b8cab..13d84f6c 100644 --- a/src/daemon/protocol_v2/command/layout.rs +++ b/src/daemon/protocol_v2/command/layout.rs @@ -8,6 +8,7 @@ use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; +pub(super) use super::super::linux::create_private_directory; use super::super::wire::{ AdmissionRecord, CommandControl, DAEMON_COMMAND_PROTOCOL_VERSION, MAX_ADMISSION_RECORD_BYTES, MAX_CONTROL_RECORD_BYTES, NamespaceIdentityV2, canonical_json, fresh_id, parse_canonical_json, @@ -58,8 +59,6 @@ pub(super) fn queue_path(root: &Path, order: u64, identity: &str) -> PathBuf { queue_dir(root).join(queue_name(order, identity)) } -pub(super) use super::super::linux::create_private_directory; - pub(crate) fn prepare_layout(root: &Path) -> Result<()> { if let Some(parent) = root.parent() { fs::create_dir_all(parent) diff --git a/src/daemon/protocol_v2/linux.rs b/src/daemon/protocol_v2/linux.rs index d2c007d5..26a1680f 100644 --- a/src/daemon/protocol_v2/linux.rs +++ b/src/daemon/protocol_v2/linux.rs @@ -202,7 +202,8 @@ pub(crate) fn is_private(metadata: &std::fs::Metadata) -> bool { } /// Creates `path` as a directory only this user may use, or makes an existing -/// directory so. A symlink or anything but a directory is refused. +/// directory this user owns so. A symlink, anything but a directory, or a +/// directory another user owns is refused. pub(crate) fn create_private_directory(path: &Path) -> anyhow::Result<()> { use std::os::unix::fs::PermissionsExt; @@ -211,8 +212,10 @@ pub(crate) fn create_private_directory(path: &Path) -> anyhow::Result<()> { Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} Err(error) => return Err(error.into()), } + let metadata = std::fs::symlink_metadata(path)?; - if !metadata.is_dir() { + // SAFETY: geteuid has no preconditions and cannot fail. + if !metadata.is_dir() || metadata.uid() != unsafe { libc::geteuid() } { anyhow::bail!("{} is not a no-follow private directory", path.display()); } std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; diff --git a/src/daemon/protocol_v2/session_target.rs b/src/daemon/protocol_v2/session_target.rs index 066cef70..55b6adcc 100644 --- a/src/daemon/protocol_v2/session_target.rs +++ b/src/daemon/protocol_v2/session_target.rs @@ -7,6 +7,7 @@ //! know, and an older daemon reads only the plain files directly in //! `daemon-commands/`. +use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow, bail}; @@ -43,8 +44,12 @@ fn report_dir() -> PathBuf { crate::paths::daemon_command_dir().join("overlay-targets") } +fn report_name(generation: &str) -> String { + format!("{generation}.target") +} + fn report_path(generation: &str) -> PathBuf { - report_dir().join(format!("{generation}.target")) + report_dir().join(report_name(generation)) } /// Reports `session` to the daemon that launched this overlay, replacing any @@ -135,7 +140,7 @@ fn read_session_report( let Some(directory) = private_report_dir()? else { return Ok(None); }; - let path = directory.join(format!("{generation}.target")); + let path = directory.join(report_name(generation)); let bytes = match super::linux::read_bounded_private_file(&path, MAX_REPORT_BYTES) { Ok(bytes) => bytes, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), @@ -156,8 +161,11 @@ fn read_session_report( if !path.is_absolute() { bail!("reported session {} is not absolute", path.display()); } - // The shape rules any named session file must follow. - crate::session::validate_named_session_file_for_info(&path)?; + // Only its shape: the file may change before the next show, and the + // overlay that continues it checks it then and goes home if it must. + if path.file_name().is_none() || path.as_os_str().as_bytes().ends_with(b"/") { + bail!("reported session {} does not name a file", path.display()); + } Ok(Some(ReportedSession::Named(path))) } @@ -189,7 +197,7 @@ pub(crate) fn take_final_session_report( /// Removes the report of child `generation` and any temporary its writer left. pub(crate) fn discard_session_report(generation: &str) { - let report = format!("{generation}.target"); + let report = report_name(generation); if let Err(error) = remove_reports(Some(&report), |target| target == report) { log::warn!("Failed to remove the session report of overlay child {generation}: {error:#}"); } diff --git a/src/env_vars.rs b/src/env_vars.rs index 12201030..1e61e428 100644 --- a/src/env_vars.rs +++ b/src/env_vars.rs @@ -17,8 +17,8 @@ pub(crate) const OVERLAY_SESSION_REPORTS_ENV: &str = "WAYSCRIBER_OVERLAY_SESSION /// The daemon's startup session file, which its overlays return home to. /// Absent, home is the configured default session. pub(crate) const OVERLAY_HOME_SESSION_ENV: &str = "WAYSCRIBER_OVERLAY_HOME_SESSION"; -/// The session file a daemon overlay continues in place of home, if it still -/// exists. +/// The session file a daemon overlay continues in place of home, while it is +/// still a usable session file. pub(crate) const OVERLAY_PREFERRED_SESSION_ENV: &str = "WAYSCRIBER_OVERLAY_PREFERRED_SESSION"; pub const CATALOG_HOOKS_TEST_ENV: &str = "WAYSCRIBER_ENABLE_CATALOG_HOOKS_IN_TESTS"; /// Disables the periodic update check regardless of `[updates] check`. diff --git a/src/test_fake_helper.rs b/src/test_fake_helper.rs index 81383699..20082f0d 100644 --- a/src/test_fake_helper.rs +++ b/src/test_fake_helper.rs @@ -8,8 +8,9 @@ //! A test that needs a helper program therefore writes no script and relies on //! no system program for it. -use std::ffi::OsStr; +use std::ffi::{OsStr, OsString}; use std::io::Read; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; @@ -92,7 +93,7 @@ extern "C" fn play_fake_helper() { let Ok(mut arguments) = launch_arguments() else { return; }; - if arguments.is_empty() || OsStr::new(&arguments.remove(0)) != link { + if arguments.is_empty() || arguments.remove(0) != link { return; } @@ -106,7 +107,7 @@ extern "C" fn play_fake_helper() { std::process::exit(status); } -fn play(arguments: &[String]) -> Result<()> { +fn play(arguments: &[OsString]) -> Result<()> { let role = std::env::var(ROLE_ENV).context("fake helper role")?; let role = Role::ALL .into_iter() @@ -114,7 +115,7 @@ fn play(arguments: &[String]) -> Result<()> { .with_context(|| format!("unknown fake helper role {role:?}"))?; let output = std::env::var_os(OUTPUT_ENV).context("fake helper output")?; - let report = match role { + let report: Vec = match role { Role::Exit => return Ok(()), Role::ReportProcessGroup => { // SAFETY: getpgrp has no preconditions and cannot fail. @@ -124,24 +125,24 @@ fn play(arguments: &[String]) -> Result<()> { } else { "session-eligible" } - .to_owned() + .into() } Role::CountInput => { let mut input = Vec::new(); std::io::stdin() .read_to_end(&mut input) .context("read the helper's input")?; - format!("{}\n", input.len()) + format!("{}\n", input.len()).into() } Role::RecordArguments => arguments .iter() - .map(|argument| format!("{argument}\n")) + .flat_map(|argument| [argument.as_bytes(), b"\n"].concat()) .collect(), }; crate::durable_io::write_atomic( Path::new(&output), - report.as_bytes(), + &report, crate::durable_io::AtomicWriteOptions::private_runtime_file(), )?; Ok(()) @@ -149,12 +150,14 @@ fn play(arguments: &[String]) -> Result<()> { /// The arguments this process was launched with, `argv[0]` first, for a /// constructor that runs before `main`: std's own argument capture may not have -/// run yet. -pub(crate) fn launch_arguments() -> Result> { +/// run yet. They are kept as bytes, so an argument that is not UTF-8 cannot +/// stop a helper start from recognising itself. +pub(crate) fn launch_arguments() -> Result> { let raw = std::fs::read("/proc/self/cmdline").context("read /proc/self/cmdline")?; let raw = raw.strip_suffix(b"\0").unwrap_or(&raw); - raw.split(|byte| *byte == 0) - .map(|argument| String::from_utf8(argument.to_vec()).context("non-UTF-8 argument")) - .collect() + Ok(raw + .split(|byte| *byte == 0) + .map(|argument| OsString::from_vec(argument.to_vec())) + .collect()) } From e190a47d6738fa93487329a6bc3dda19244211f5 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:02:49 +0200 Subject: [PATCH 14/14] docs: state when session reports are read and what a remembered file loses The protocol doc said reports are read only at retirement and removed either way. It now says the visible-target guard also reads the current report, and that nothing in a report directory that is not private is read or removed. CONFIG.md says that a moved or deleted remembered file opens home even when a backup or recovery copy is left beside it, unlike a startup --session-file, and its session command list renders as one tight list again. --- docs/CONFIG.md | 3 +-- docs/daemon-protocol-v2.md | 9 ++++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index f9c71c55..37337601 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -2113,10 +2113,9 @@ Use the CLI helpers for quick maintenance: - `wayscriber --freeze --session-file ~/Documents/lecture-04.wayscriber-session` starts frozen mode with that same named session target. - `wayscriber --daemon --session-file ~/Documents/lecture-04.wayscriber-session` starts a daemon whose overlay activations use that named session target, the daemon's home session. - `wayscriber --daemon-toggle --session-file ~/Documents/meeting.wayscriber-session` asks the running daemon to launch a hidden overlay with that named session target. It applies to that activation only. If the overlay is already visible with a different target, hide it before switching. - - `wayscriber --session-info --session-file `, `wayscriber --clear-session --session-file `, and `wayscriber --clear-tool-state --session-file ` target only that named file. -The daemon's overlay keeps its session across hide and show. After **Open** or **Save As** switches the overlay to another session, the next activation continues in that session, until the overlay returns to the daemon's home session: the file given with `--daemon --session-file`, or the configured default session without one. The Session popover's `Back to ` or `Default session` button returns home. A `--daemon-toggle --session-file` request takes precedence for its activation. If the remembered file was moved, deleted, or replaced by something other than a regular file, the overlay opens the home session instead and says so. The daemon keeps this only while it runs; a restarted daemon starts at home. A daemon started with `--no-resume-session` still continues a named session the overlay switched to, since a named session file always persists; its default home stays unsaved. +The daemon's overlay keeps its session across hide and show. After **Open** or **Save As** switches the overlay to another session, the next activation continues in that session, until the overlay returns to the daemon's home session: the file given with `--daemon --session-file`, or the configured default session without one. The Session popover's `Back to ` or `Default session` button returns home. A `--daemon-toggle --session-file` request takes precedence for its activation. If the remembered file was moved, deleted, or replaced by something other than a regular file, the overlay opens the home session instead and says so, even when a backup or recovery copy is left beside the old path; unlike a `--session-file` given at startup, a remembered session is never restored from those copies. The daemon keeps this only while it runs; a restarted daemon starts at home. A daemon started with `--no-resume-session` still continues a named session the overlay switched to, since a named session file always persists; its default home stays unsaved. Config values seed startup defaults. When `restore_tool_state = true`, the saved session tool state is applied after those defaults, so edits such as `[arrow] head_at_end = true` can appear ignored if the session snapshot still stores an older arrow setting. Run `wayscriber --clear-tool-state` (or add `--session-file ` for a named session) to make config defaults apply on the next startup without deleting saved boards. In a running overlay, Command Palette -> Reset Tool Defaults clears the saved layer for the active session and immediately applies config defaults to the current tools so the next autosave keeps those defaults. diff --git a/docs/daemon-protocol-v2.md b/docs/daemon-protocol-v2.md index 00ca7316..fb1d5112 100644 --- a/docs/daemon-protocol-v2.md +++ b/docs/daemon-protocol-v2.md @@ -83,9 +83,12 @@ An overlay with a published child identity reports its session in it. The canonical JSON record carries a schema version, the generation, PID and process-start identity, and a target: an absolute session file, or null for home. It is replaced on each change. When the child is retired, on exit, stop or forced reap, the daemon reads the report before it -releases the child's identity, accepts it only from a private regular file in a private directory -with exactly the identity captured at readiness, and removes it either way. The remembered session -lives only in daemon memory; startup removes reports an earlier daemon left without restoring them. +releases the child's identity and removes it, along with any temporary its writer left. While the +child runs, the visible-target guard reads the current report without removing it. Either read +accepts a report only from a private regular file in a real private directory, with exactly the +identity captured at readiness; anything else is ignored. If the directory itself is not private, +nothing in it is read or removed. The remembered session lives only in daemon memory; startup +removes reports an earlier daemon left without restoring them. ## Compatibility and rollback