diff --git a/README.md b/README.md index 10cd9510a..4b998b46d 100644 --- a/README.md +++ b/README.md @@ -1211,8 +1211,8 @@ 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 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. +- `--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, 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 0acbaff47..373376013 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -2111,10 +2111,12 @@ 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. - `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, 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. 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. @@ -2125,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, 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 26032b38a..ae0174753 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -539,18 +539,21 @@ 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 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:** 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/docs/daemon-protocol-v2.md b/docs/daemon-protocol-v2.md index 8995b6599..fb1d5112c 100644 --- a/docs/daemon-protocol-v2.md +++ b/docs/daemon-protocol-v2.md @@ -69,6 +69,27 @@ 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 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 - A v2 client against a v1 daemon uses the strict legacy parser and v1 request path. @@ -76,6 +97,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/examples/session-manager.md b/examples/session-manager.md index 5bd5c755d..c5676451c 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/backend/helpers.rs b/src/backend/wayland/backend/helpers.rs index ca32d3bfc..88b6016cf 100644 --- a/src/backend/wayland/backend/helpers.rs +++ b/src/backend/wayland/backend/helpers.rs @@ -284,10 +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 { - if let Some(runtime) = runtime_session_override() { - return Some(runtime); - } + runtime_session_override().or_else(launched_resume_policy) +} + +/// 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(); @@ -309,21 +316,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 +807,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 +826,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 802f3008d..a8f3c62a8 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,24 @@ 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::home_session_options(&config, &config_dir, &launch); + 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 +211,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, @@ -214,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 35637246f..b10f96214 100644 --- a/src/backend/wayland/backend/state_init/session.rs +++ b/src/backend/wayland/backend/state_init/session.rs @@ -2,19 +2,60 @@ 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::{launched_resume_policy, 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, + 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 { + launched_resume_policy() + } 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, 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, @@ -76,7 +117,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 +144,6 @@ pub(super) fn build_session_options( } else { info!("Session persistence disabled (no session options available)"); } - - session_options } #[cfg(test)] @@ -130,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 3ea2280ff..fe3cf229a 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> { @@ -215,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; @@ -579,9 +601,20 @@ fn autosave_active(options: &SessionOptions) -> bool { } pub(in crate::backend::wayland) mod driver; +mod home; +mod load_outcome; mod persistence; mod runtime; +#[cfg(test)] +pub(in crate::backend::wayland) use home::HomeSession; +pub(in crate::backend::wayland) use home::{ + 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, +}; 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 44cff1073..ab1a55b51 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(); @@ -140,11 +144,14 @@ 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(); } + 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 abd032731..2667f323d 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,319 @@ 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); + } +} + +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); + let epoch = runtime.session.target_epoch(); + + 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(), "{:?}", runtime.errors); + assert!(matches!( + runtime.reports.as_slice(), + [SessionCommandReport::Home] + )); + 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)) + ); +} + +/// 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() { + 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(); + 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), + "{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, "{case}"); + assert!(matches!( + shapes[0].shape, + crate::draw::Shape::Line { x2: 51, .. } + )); + assert!(runtime.committed_targets.is_empty()); + } +} + +#[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); + assert_eq!(runtime.input.boards.active_frame().shapes.len(), 1); +} + +#[test] +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(); + 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(), "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] 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 000000000..b32215f03 --- /dev/null +++ b/src/backend/wayland/session/home.rs @@ -0,0 +1,288 @@ +//! 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 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::{LoadSnapshotOutcome, 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, 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. + pub(in crate::backend::wayland) from_daemon: bool, +} + +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, + from_daemon: false, + }; + } + + 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, + from_daemon: true, + } + } +} + +/// 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 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 { + /// `startup` is the target this run starts in. + pub(in crate::backend::wayland) fn new( + launch: SessionLaunch, + options: Option, + startup: SessionTarget, + ) -> Self { + Self { + at_home: is_home(&launch.home, &startup), + reported: launch.preferred.is_none().then(|| startup.clone()), + home: launch.home, + options, + remembered: launch.preferred, + current: startup, + } + } + + /// 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 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. + 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 { + self.name() + .unwrap_or_else(|| "the default session".to_owned()) + } + + /// 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`. + 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; + } + + Some(match &self.current { + SessionTarget::NamedFile(path) if !self.at_home => ReportedSession::Named(path.clone()), + _ => ReportedSession::Home, + }) + } + + /// 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()); + } +} + +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, + } +} + +/// 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()) +} + +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) 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 +/// 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 options = staged.clone(); + let operation = if is_remembered(&staged, remembered) { + PersistenceOperation::LoadRemembered { options } + } else { + PersistenceOperation::LoadConfigured { options } + }; + + 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 home = 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::WentHome { + remembered: staged.session_file_path(), + reason, + home, + }) +} + +#[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 000000000..d8024c41e --- /dev/null +++ b/src/backend/wayland/session/home/tests.rs @@ -0,0 +1,437 @@ +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)), + from_daemon: true, + } + ); +} + +#[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)), + from_daemon: true, + } + ); +} + +#[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, + from_daemon: false, + } + ); + assert_eq!( + launch(None, &environment), + SessionLaunch { + home: HomeSession::Default, + preferred: None, + from_daemon: false, + } + ); + } +} + +fn home_session(home: HomeSession, preferred: Option<&str>, startup: SessionTarget) -> SessionHome { + SessionHome::new( + SessionLaunch { + home, + preferred: preferred.map(PathBuf::from), + from_daemon: true, + }, + None, + startup, + ) +} + +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 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!( + continued.unreported(), + Some(ReportedSession::Named(PathBuf::from(PREFERRED))) + ); + + 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), None, file(HOME)); + + 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.unreported(), + Some(ReportedSession::Named(PathBuf::from(other))) + ); +} + +#[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!( + report(&mut named_home, file(alias)), + Some(ReportedSession::Home) + ); + + let mut default_home = home_session(HomeSession::Default, None, file(PREFERRED)); + assert_eq!( + report(&mut default_home, SessionTarget::Configured), + Some(ReportedSession::Home) + ); + assert_eq!( + report(&mut default_home, file(HOME)), + Some(ReportedSession::Named(PathBuf::from(HOME))) + ); +} + +#[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.enter(file("/sessions/./home.wayscriber-session")); + assert!(named_home.is_at_home()); + 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.enter(SessionTarget::Configured); + assert!(default_home.is_at_home()); + assert!(home_session(HomeSession::Default, None, SessionTarget::Configured).is_at_home()); +} + +#[test] +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 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(_))); + assert_eq!(remembered, self.remembered.session_file_path()); + reason + } + } + + #[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 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(_))); + } + + #[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(); + + // 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(); + std::fs::remove_file(sessions.remembered.session_file_path()).unwrap(); + + let load = sessions.load(None).unwrap(); + + assert!(matches!( + load, + OutputSessionLoad::WentHome { home: None, .. } + )); + } + + #[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 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)); + } +} diff --git a/src/backend/wayland/session/load_outcome.rs b/src/backend/wayland/session/load_outcome.rs new file mode 100644 index 000000000..7f3a3fbb9 --- /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 ee3d1c06a..21342f29a 100644 --- a/src/backend/wayland/session/persistence.rs +++ b/src/backend/wayland/session/persistence.rs @@ -52,6 +52,20 @@ 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, + }, + /// 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 { + options: SessionOptions, + }, Inspect { options: SessionOptions, }, @@ -93,6 +107,9 @@ impl PersistenceOperation { Self::SaveAs { .. } => "save-as", Self::LoadConfigured { .. } => "load-configured", 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", @@ -127,6 +144,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 3726285fe..102c78075 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 } => 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)?, )), @@ -179,6 +187,66 @@ 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 +/// is one that went away while it loaded. +fn load_remembered(options: &SessionOptions) -> 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/session/runtime/transaction.rs b/src/backend/wayland/session/runtime/transaction.rs index 218dd98dc..1c3652070 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,8 @@ pub(in crate::backend::wayland) enum SessionCommand { pub(in crate::backend::wayland) enum SessionCommandReport { Open(RuntimeOpenSessionReport), + /// The overlay is back in its home session. + Home, SaveAs(RuntimeSaveAsSessionReport), Overwrite(PathBuf, bool), Clear(RuntimeClearSessionReport), @@ -36,6 +41,7 @@ enum Phase { SaveCurrent, Load, RecordOpen, + LoadHome, SaveAsPreflight, SaveAs, Clear, @@ -117,6 +123,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 +203,19 @@ 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::LoadHome { options }) + } + SessionCommand::OpenHome(None) => { + self.leave_persistence_for_home(context)?; + Ok(TransactionStep::Complete(Box::new( + SessionCommandReport::Home, + ))) + } 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 99e80d0aa..253564753 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,80 @@ impl ExplicitSessionTransaction { ))) } + /// 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<'_>, + 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!() + }; + 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(); + 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())); + context.input_state.clear_session_dirty(); + context + .session + .commit_output_options(options, loaded_board_data); + + Ok(TransactionStep::Complete(Box::new( + 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.rs b/src/backend/wayland/state.rs index 42fa36d51..e3023dd6a 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 f2441dfd3..7c35f1bd2 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 14e1f1c07..a14e2fd57 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.rs b/src/backend/wayland/state/core/output.rs index abfab083b..97ac8756f 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 9ac375220..b61836537 100644 --- a/src/backend/wayland/state/core/output/session_ops.rs +++ b/src/backend/wayland/state/core/output/session_ops.rs @@ -1,27 +1,91 @@ use super::*; +use crate::backend::wayland::session::{ + ExpandedTooLarge, OutputSessionLoad, 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 + /// `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, + staged: session::SessionOptions, + physical_output_identity: Option<&str>, + context: &str, + ) -> anyhow::Result<()> { + let remembered = self.unloaded_remembered_session(); + 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 { + OutputSessionLoad::Loaded(options, outcome) => { + self.commit_output_session(options, outcome, context)?; + } + 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); + } + } + + self.report_session_to_daemon(); + Ok(()) + } + + fn commit_output_session( &mut self, options: session::SessionOptions, + outcome: session::LoadSnapshotOutcome, 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)?; + 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 + .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 @@ -64,136 +128,46 @@ 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, 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 d64380dd8..d933b05f9 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/core/output/transition.rs b/src/backend/wayland/state/core/output/transition.rs index 41e6b27a8..e7253f6ef 100644 --- a/src/backend/wayland/state/core/output/transition.rs +++ b/src/backend/wayland/state/core/output/transition.rs @@ -66,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 @@ -225,21 +226,15 @@ 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)?; + } - 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 954dd6426..a1d0a025c 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) { + self.report_session_to_daemon(); + } + 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 000000000..eda62eebf --- /dev/null +++ b/src/backend/wayland/state/core/session_home.rs @@ -0,0 +1,60 @@ +use std::path::Path; + +use log::{info, warn}; + +use super::super::*; +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 { + pub(in crate::backend::wayland::state) fn notify_remembered_session_abandoned( + &mut self, + path: &Path, + error: &anyhow::Error, + ) { + info!( + "Remembered session {} cannot be continued; starting at home: {error:#}", + path.display() + ); + 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!("{message}; opened {}", self.session_home.label())), + ); + } + + /// Tells the daemon that launched this overlay the session it is now in, + /// 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; + }; + + 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.rs b/src/backend/wayland/state/toolbar/events.rs index db386b47c..f0bac09f5 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 b459ed3aa..8b14a7b2a 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}; @@ -9,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 @@ -22,13 +26,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 { @@ -110,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}; @@ -134,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 @@ -425,6 +427,7 @@ impl WaylandState { self.set_session_toolbar_info(format!("Opened session {name}")); } } + SessionCommandReport::Home => self.finish_open_home_session(), SessionCommandReport::SaveAs(report) => { self.clear_toolbar_save_as_overwrite_prompt(); self.set_session_toolbar_info(format!( @@ -509,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", @@ -618,8 +622,35 @@ 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, + from_daemon: true, + }, + 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 000000000..61d2c4add --- /dev/null +++ b/src/backend/wayland/state/toolbar/events/session/home.rs @@ -0,0 +1,37 @@ +use super::*; +use crate::session::SessionOptions; + +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 { + 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)) + } + + /// 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.set_session_toolbar_info(format!("Returned to {}", self.session_home.label())); + } +} diff --git a/src/backend/wayland/state/toolbar/events/tests.rs b/src/backend/wayland/state/toolbar/events/tests.rs index 8827cca2e..aeb0bc55b 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 80bb71f27..81882decd 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 f5b199b0d..6f05f9565 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 3ade29a41..68544e06e 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 8bdff4100..dd3288c52 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/daemon/AGENTS.md b/src/daemon/AGENTS.md index 1cdda13b0..d92593b92 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 7657493ed..3a5b2bbb1 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 48ae00642..d4e868365 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 9d4a00411..13c7f4f73 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 d3b3d631a..46bb7daec 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 583680621..c39409429 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 d14976fcc..e8d650a66 100644 --- a/src/daemon/overlay/lifecycle/stop.rs +++ b/src/daemon/overlay/lifecycle/stop.rs @@ -6,10 +6,36 @@ 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, +}; + +/// 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 { - fn terminate(&mut self) -> Result<()> { + /// Stops the child, returning the session it last reported. + 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(); let timeout = Duration::from_secs(2); @@ -31,12 +57,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 +72,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 @@ -69,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(), + }); } } } @@ -86,24 +117,29 @@ 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, + ) -> std::result::Result, StopFailure> { + 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 e6de2b969..497bce487 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,37 @@ impl Daemon { return Ok(()); } - self.overlay.hide()?; + 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(()) } + + /// 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 4e7df083d..0e6243369 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 3278e985b..0ca6e34c4 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 20d55bc9e..358dd9c92 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 946c856d3..5c555c321 100644 --- a/src/daemon/overlay/tests/fake_overlay.rs +++ b/src/daemon/overlay/tests/fake_overlay.rs @@ -3,21 +3,22 @@ //! 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; -use std::os::unix::ffi::OsStrExt; use std::path::Path; 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 +36,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 +94,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 +139,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 +152,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,30 +162,30 @@ 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 { - 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")? + crate::test_fake_helper::launch_arguments()? .into_iter() .skip(1) - .map(|argument| String::from_utf8(argument).context("non-UTF-8 launch argument")) + .map(|argument| { + argument + .into_string() + .map_err(|_| anyhow::anyhow!("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()) -} diff --git a/src/daemon/protocol_v2/action.rs b/src/daemon/protocol_v2/action.rs index bf7e3ccd6..6e9c45204 100644 --- a/src/daemon/protocol_v2/action.rs +++ b/src/daemon/protocol_v2/action.rs @@ -2,13 +2,14 @@ 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}; 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,20 +198,6 @@ 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(()) -} - 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/child.rs b/src/daemon/protocol_v2/child.rs index efc8ac6d7..2da9ce56f 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_trusted_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,24 @@ 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_trusted_session_report(generation, owned.display_pid, process_start_ticks) + } + + 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 +314,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 +323,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/command/layout.rs b/src/daemon/protocol_v2/command/layout.rs index d2052c0bc..13d84f6cc 100644 --- a/src/daemon/protocol_v2/command/layout.rs +++ b/src/daemon/protocol_v2/command/layout.rs @@ -2,12 +2,13 @@ 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; 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,24 +59,6 @@ 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(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 27a72dfc1..26a1680f3 100644 --- a/src/daemon/protocol_v2/linux.rs +++ b/src/daemon/protocol_v2/linux.rs @@ -186,6 +186,47 @@ 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 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; + + 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)?; + // 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))?; + 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 +239,12 @@ pub(crate) fn read_bounded_regular_file(path: &Path, cap: usize) -> io::Result.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::os::unix::ffi::OsStrExt; +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_name(generation: &str) -> String { + format!("{generation}.target") +} + +fn report_path(generation: &str) -> PathBuf { + report_dir().join(report_name(generation)) +} + +/// 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)?; + super::linux::create_private_directory(&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() + ) + }) +} + +/// 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::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) => { + Err(error).with_context(|| format!("failed to inspect {}", directory.display())) + } + } +} + +/// 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. +fn read_session_report( + generation: &str, + pid: u32, + process_start_ticks: u64, +) -> Result> { + super::wire::validate_id(generation)?; + let Some(directory) = private_report_dir()? else { + return Ok(None); + }; + 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), + 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"); + } + + 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()); + } + // 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))) +} + +/// 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. 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_trusted_session_report(generation, pid, process_start_ticks); + 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 = 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:#}"); + } +} + +/// 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(None, |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 `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) { + remove_entry(&entry.path())?; + } + } + Ok(()) +} + +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())), + } +} + +#[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 000000000..bc7740edd --- /dev/null +++ b/src/daemon/protocol_v2/session_target/tests.rs @@ -0,0 +1,237 @@ +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()); + }); +} + +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(); + 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(); + 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 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"); + 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), + ("a directory as target", &directory, 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())); + }); +} + +#[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()); + }); +} diff --git a/src/daemon/tests.rs b/src/daemon/tests.rs index a1a823224..8744352cc 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/env_vars.rs b/src/env_vars.rs index a3720cb2c..1e61e4283 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, 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`. pub const DISABLE_UPDATE_CHECK_ENV: &str = "WAYSCRIBER_DISABLE_UPDATE_CHECK"; diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs index 4d8e7f502..9a3f520f7 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/lib.rs b/src/lib.rs index 667a77b7c..bf90d58b0 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 50e3f312d..b56e1a084 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/manifest.rs b/src/process_broker/manifest.rs index da027a4d9..4467a48b3 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 diff --git a/src/process_broker/tests.rs b/src/process_broker/tests.rs index a57553c5f..aae321d3a 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 000000000..20082f0d4 --- /dev/null +++ b/src/test_fake_helper.rs @@ -0,0 +1,163 @@ +//! 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. +//! A test that needs a helper program therefore writes no script and relies on +//! no system program for it. + +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}; + +/// 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() || 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: &[OsString]) -> 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: Vec = 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" + } + .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()).into() + } + Role::RecordArguments => arguments + .iter() + .flat_map(|argument| [argument.as_bytes(), b"\n"].concat()) + .collect(), + }; + + crate::durable_io::write_atomic( + Path::new(&output), + &report, + crate::durable_io::AtomicWriteOptions::private_runtime_file(), + )?; + Ok(()) +} + +/// 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. 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); + + Ok(raw + .split(|byte| *byte == 0) + .map(|argument| OsString::from_vec(argument.to_vec())) + .collect()) +} 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 3178dcbc4..b2ababdbc 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) { diff --git a/src/toolbar_gtk/view/sections/session_pane.rs b/src/toolbar_gtk/view/sections/session_pane.rs index 6120b509a..f4dedbb54 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 f88617d70..ab0c527d3 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 8081741e3..f4424021c 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 73463ef98..f87480458 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 13c4f2584..196fc30fd 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 d643860ca..3f5e9c832 100644 --- a/src/ui/toolbar/model/mod.rs +++ b/src/ui/toolbar/model/mod.rs @@ -684,6 +684,21 @@ 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") + .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 24631ebbb..98ad4d5f9 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; @@ -12,6 +13,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 +46,11 @@ impl ToolbarSessionModel { .into_iter() .filter(|button| session_button_visible(snapshot, &button.event)) .collect(); + // 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 @@ -58,14 +65,15 @@ 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(), }); - (!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 +117,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, @@ -128,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)) } @@ -143,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, @@ -166,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), @@ -217,9 +246,37 @@ 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"); } + #[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/session_format.rs b/src/ui/toolbar/session_format.rs index b0db907e7..0a6937af3 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. diff --git a/src/ui/toolbar/snapshot/build.rs b/src/ui/toolbar/snapshot/build.rs index af3e88e12..0a06dfc13 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 38d81aa8c..2c91808db 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. diff --git a/tests/repository_guards/no_python.rs b/tests/repository_guards/no_python.rs index 8333ca386..ed6b3ce80 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,13 @@ //! 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 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; @@ -77,7 +82,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 657f73976..fb1764c52 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 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`