From bcf1e69cf4c8551b3536eb9cd9af1dcb0bab5d63 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:37:20 -0500 Subject: [PATCH] craft-window: restore store: window.json beside config.toml and a pure place() * craft-window: restore store: window.json beside config.toml and a pure place() * craft-window: restore: review fixes (bar strip, mixed DPI, pending writes, work area) --------- Co-authored-by: aiongg --- .github/workflows/window.yml | 2 + Cargo.lock | 5 + crates/window/Cargo.toml | 9 + crates/window/README.md | 18 + crates/window/src/lib.rs | 8 + crates/window/src/restore/legacy.rs | 281 +++++++++++++++ crates/window/src/restore/mod.rs | 30 ++ crates/window/src/restore/place.rs | 538 ++++++++++++++++++++++++++++ crates/window/src/restore/store.rs | 414 +++++++++++++++++++++ 9 files changed, 1305 insertions(+) create mode 100644 crates/window/src/restore/legacy.rs create mode 100644 crates/window/src/restore/mod.rs create mode 100644 crates/window/src/restore/place.rs create mode 100644 crates/window/src/restore/store.rs diff --git a/.github/workflows/window.yml b/.github/workflows/window.yml index b07bce384..f570ef5ce 100644 --- a/.github/workflows/window.yml +++ b/.github/workflows/window.yml @@ -46,3 +46,5 @@ jobs: - run: cargo clippy -p craft-window --all-targets --locked --features egui -- -D warnings - run: cargo test -p craft-window --locked -- --nocapture - run: cargo test -p craft-window --locked --features egui -- --nocapture + - run: cargo clippy -p craft-window --all-targets --locked --features restore -- -D warnings + - run: cargo test -p craft-window --locked --features restore,egui -- --nocapture diff --git a/Cargo.lock b/Cargo.lock index c49807145..cf0e0bd47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2386,9 +2386,14 @@ dependencies = [ name = "craft-window" version = "0.1.0" dependencies = [ + "craft-atomic", + "craft-settings", "egui", "objc2 0.6.5", "objc2-app-kit 0.3.2", + "serde", + "serde_json", + "tempfile", "wayland-client", ] diff --git a/crates/window/Cargo.toml b/crates/window/Cargo.toml index 2ccc0765b..39efa5f1c 100644 --- a/crates/window/Cargo.toml +++ b/crates/window/Cargo.toml @@ -11,9 +11,15 @@ repository.workspace = true # `decorate`: set the decision on an `egui::ViewportBuilder`. `title_bar`: an app-drawn strip as # the title bar (on macOS with AppKit's `movable` flag). `Viewports`: one immediate viewport per window. egui = ["dep:egui", "dep:objc2", "dep:objc2-app-kit"] +# `restore`: the window-restore store (`window.json` beside `config.toml`) and the pure `place`. No egui. +restore = ["dep:craft-atomic", "dep:craft-settings", "dep:serde", "dep:serde_json"] [dependencies] egui = { workspace = true, optional = true } +craft-atomic = { workspace = true, optional = true } +craft-settings = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } # Only Linux and the BSDs probe a Wayland compositor; everywhere else the frame is the OS's. [target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))'.dependencies] @@ -24,5 +30,8 @@ wayland-client = "0.31" objc2 = { version = "0.6.4", default-features = false, features = ["std"], optional = true } objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSApplication", "NSButton", "NSControl", "NSResponder", "NSView", "NSWindow"], optional = true } +[dev-dependencies] +tempfile = "3" + [lints] workspace = true diff --git a/crates/window/README.md b/crates/window/README.md index 2816b9fe8..e90e8730d 100644 --- a/crates/window/README.md +++ b/crates/window/README.md @@ -143,6 +143,24 @@ for event in viewports.show( Immediate viewports repaint every window when one repaints, so cache expensive per-window drawing. +## Window restore + +Feature `restore` (no egui): `craft_window::restore`. + +- `place(saved, monitors, home, &Defaults) -> Placement` is pure. First launch: the default size fitted + to the home monitor, maximized when it does not fit. Saved sizes are points at interface zoom 1. + A window with less than 64×64 pt of its top strip on any one monitor moves to its saved monitor, + else the nearest, else the primary; sizes clamp to the monitor's usable area and `min`. Positions + are physical pixels (negative left of or above the primary). `Defaults::no_position` (Wayland) + neither reads nor returns a position. Full screen returns only with `Defaults::restore_fullscreen`. +- `Store::open(app_id)` keeps `window.json` beside `config.toml` (same folder rules as + `craft-settings`), `Store::in_memory()` keeps nothing. `get`, `set`, `save`. + Format: `{ "schema_version": 1, "windows": { "main": Placement, "": Placement } }`. + Writes are atomic and private; a damaged file is moved aside as `window.broken-.json`; a newer + `schema_version` is read-only; unknown keys are written back unchanged. +- `restore::legacy` reads eframe's `"window"` entry of `app.ron` (without a RON crate) and has small + JSON helpers for an app's own old format. + ## Notes - The Wayland probe opens a short-lived connection and reads the global registry once, on its own diff --git a/crates/window/src/lib.rs b/crates/window/src/lib.rs index f85c256f5..cdaed6a84 100644 --- a/crates/window/src/lib.rs +++ b/crates/window/src/lib.rs @@ -127,6 +127,11 @@ //! } //! } //! ``` +//! +//! # Window restore +//! +//! The `restore` feature (no egui) adds [`restore`]: where a window opens, from the last session's +//! geometry and the connected monitors, and the `window.json` store beside `config.toml`. mod list; mod menu; @@ -136,6 +141,9 @@ pub mod title_bar; #[cfg(feature = "egui")] mod viewports; +#[cfg(feature = "restore")] +pub mod restore; + pub use list::{Closed, WindowId, WindowList}; pub use menu::{Document, MenuEntry, WindowMenu}; pub use place::{CASCADE_STEP, Rect, cascade, monitor_of}; diff --git a/crates/window/src/restore/legacy.rs b/crates/window/src/restore/legacy.rs new file mode 100644 index 000000000..b0f569f62 --- /dev/null +++ b/crates/window/src/restore/legacy.rs @@ -0,0 +1,281 @@ +//! Readers for window geometry that apps stored before the `window.json` store. +//! +//! eframe keeps a RON map of strings in `app.ron` (`ui.ron`); its `"window"` entry is the RON of +//! egui-winit's `WindowSettings`, and `"egui"` holds the interface zoom. No RON crate is +//! involved: the readers look up the few named fields they need, in both layouts eframe has +//! used (a flat struct, and one with a nested `normal_geometry`). Nothing is written back; old +//! builds keep working from the old file. +//! +//! An old layout has no scale factor, so imports assume 1; [`place`](super::place) takes the +//! real one from the monitor the window lands on. + +use std::path::Path; + +use serde_json::Value; + +use super::place::{NormalRect, Placement}; + +/// The placement in an eframe `app.ron` text: its `"window"` entry, with the size converted to +/// points at zoom 1 using the `"egui"` entry's `zoom_factor` (1 when absent). +pub fn eframe_app_ron(text: &str) -> Option { + let window = ron_string_entry(text, "window")?; + let zoom = ron_string_entry(text, "egui").and_then(|e| number(field(&e, "zoom_factor")?)).unwrap_or(1.0); + eframe_window_settings(&window, zoom) +} + +/// [`eframe_app_ron`] for a file; `None` when it is missing or unreadable. +pub fn eframe_app_ron_file(path: &Path) -> Option { + eframe_app_ron(&std::fs::read_to_string(path).ok()?) +} + +/// The placement in the RON of one eframe `WindowSettings`. `zoom` is the interface zoom factor +/// the size was saved under (eframe saves points at that zoom). +pub fn eframe_window_settings(ron: &str, zoom: f32) -> Option { + let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }; + // The nested layout also names the scale; the flat one does not. + let geometry = field(ron, "normal_geometry").and_then(group).unwrap_or(ron); + let scale = field(geometry, "scale_factor").and_then(number).filter(|s| *s > 0.0).unwrap_or(1.0); + let size = field(geometry, "inner_size_points").and_then(vec2)?; + let pos = field(geometry, "outer_position_pixels").and_then(vec2).or_else(|| field(geometry, "inner_position_pixels").and_then(vec2)); + // The window's pixel size divided by zoom × scale (eframe >= 0.36), so × zoom is points at + // zoom 1. Older eframe saved points at the then-current zoom, which this reads the same way. + let size = size.map(|v| v * zoom); + // Windows parks a minimized window near (-32000, -32000). + let pos = pos.filter(|p| p.iter().all(|v| *v > -30000.0)); + Some(Placement { + normal: NormalRect { pos: pos.map_or([0.0; 2], |p| p.map(|v| v / scale)), size }, + position_px: pos.map(|p| p.map(|v| v.round() as i32)), + scale, + zoom, + maximized: field(ron, "maximized").is_some_and(truth), + fullscreen: field(ron, "fullscreen").is_some_and(truth), + monitor: None, + }) +} + +/// The value at `path` of a JSON object (`json_at(&v, &["window", "size"])`). +pub fn json_at<'a>(v: &'a Value, path: &[&str]) -> Option<&'a Value> { + path.iter().try_fold(v, |v, k| v.get(*k)) +} + +/// A finite number at `path`. +pub fn json_f32(v: &Value, path: &[&str]) -> Option { + json_at(v, path)?.as_f64().filter(|n| n.is_finite()).map(|n| n as f32) +} + +/// A bool at `path`. +pub fn json_bool(v: &Value, path: &[&str]) -> Option { + json_at(v, path)?.as_bool() +} + +/// A pair of finite numbers at `path`, written as `[a, b]` or as an object with the two given keys. +pub fn json_pair(v: &Value, path: &[&str], keys: [&str; 2]) -> Option<[f32; 2]> { + let at = json_at(v, path)?; + let num = |x: Option<&Value>| x?.as_f64().filter(|n| n.is_finite()).map(|n| n as f32); + match at { + Value::Array(a) if a.len() == 2 => Some([num(a.first())?, num(a.get(1))?]), + Value::Object(_) => Some([num(at.get(keys[0]))?, num(at.get(keys[1]))?]), + _ => None, + } +} + +/// [`json_at`] on the text of a JSON file; `None` when missing or not JSON. +pub fn read_json_file(path: &Path) -> Option { + serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok() +} + +// ---- RON scanning -------------------------------------------------------------------------- + +/// The text after `name:` for the first `name` that is a whole identifier. +fn field<'a>(text: &'a str, name: &str) -> Option<&'a str> { + let mut from = 0; + while let Some(i) = text[from..].find(name) { + let start = from + i; + let end = start + name.len(); + let before_ok = text[..start].chars().next_back().is_none_or(|c| !(c.is_alphanumeric() || c == '_')); + let rest = text[end..].trim_start(); + if before_ok && let Some(value) = rest.strip_prefix(':') { + return Some(value.trim_start()); + } + from = end; + } + None +} + +/// The text inside the parentheses that `s` starts with, as `(…)` or `Some(…)`. +fn group(s: &str) -> Option<&str> { + let s = s.strip_prefix("Some").map_or(s, str::trim_start); + let s = s.strip_prefix('(')?; + let mut depth = 1usize; + for (i, c) in s.char_indices() { + match c { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return Some(&s[..i]); + } + } + _ => {} + } + } + None +} + +fn number(s: &str) -> Option { + let end = s.find(|c: char| !(c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E'))).unwrap_or(s.len()); + s[..end].parse::().ok().filter(|n| n.is_finite()) +} + +fn truth(s: &str) -> bool { + s.starts_with("true") +} + +/// `Some((x: 1.0, y: 2.0))`, `(x: 1.0, y: 2.0)` or `(1.0, 2.0)`; `None` for `None`. +fn vec2(s: &str) -> Option<[f32; 2]> { + let inner = group(s)?; + // `Some((…))` wraps the pair in a second group. + let inner = if inner.trim_start().starts_with('(') { group(inner.trim_start())? } else { inner }; + if let (Some(x), Some(y)) = (field(inner, "x").and_then(number), field(inner, "y").and_then(number)) { + return Some([x, y]); + } + let (a, b) = inner.split_once(',')?; + Some([number(a.trim())?, number(b.trim())?]) +} + +/// The string value for `key` in a RON map of strings (`{"key": "value", …}`), unescaped. +fn ron_string_entry(text: &str, key: &str) -> Option { + let mut literals = string_literals(text).into_iter(); + while let Some(k) = literals.next() { + let v = literals.next()?; + if k == key { + return Some(v); + } + } + None +} + +/// Every string literal of `text` in order: `"…"` with escapes, and raw `r#"…"#`. +fn string_literals(text: &str) -> Vec { + let mut out = Vec::new(); + let b = text.as_bytes(); + let mut i = 0; + while i < b.len() { + if b[i] == b'r' && matches!(b.get(i + 1), Some(b'"' | b'#')) { + let hashes = b[i + 1..].iter().take_while(|c| **c == b'#').count(); + if b.get(i + 1 + hashes) == Some(&b'"') { + let body = i + 2 + hashes; + let close = format!("\"{}", "#".repeat(hashes)); + let Some(len) = text[body..].find(&close) else { break }; + out.push(text[body..body + len].to_owned()); + i = body + len + close.len(); + continue; + } + } + if b[i] == b'"' { + let (s, next) = escaped(text, i + 1); + out.push(s); + i = next; + continue; + } + i += 1; + } + out +} + +/// Reads an escaped literal body starting at byte `from`; returns it and the index after the closing quote. +fn escaped(text: &str, from: usize) -> (String, usize) { + let mut s = String::new(); + let mut chars = text[from..].char_indices(); + while let Some((i, c)) = chars.next() { + match c { + '"' => return (s, from + i + 1), + '\\' => match chars.next().map(|(_, e)| e) { + Some('n') => s.push('\n'), + Some('t') => s.push('\t'), + Some('r') => s.push('\r'), + Some('u') => { + let mut hex = String::new(); + for (_, h) in chars.by_ref() { + if h == '}' { + break; + } + if h != '{' { + hex.push(h); + } + } + if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { + s.push(ch); + } + } + Some(other) => s.push(other), + None => break, + }, + c => s.push(c), + } + } + (s, text.len()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::restore::Store; + + const FLAT: &str = r#"{ + "egui": "(options:(zoom_factor:1.5,other:1))", + "window": "(outer_position_pixels:Some((x:120.0,y:-30.0)),fullscreen:false,maximized:true,inner_size_points:Some((x:800.0,y:600.0)))", +}"#; + + #[test] + fn imports_the_flat_eframe_layout_with_zoom() { + let p = eframe_app_ron(FLAT).unwrap(); + assert_eq!(p.position_px, Some([120, -30])); + assert_eq!(p.normal.size, [1200.0, 900.0]); + assert_eq!(p.zoom, 1.5); + assert!(p.maximized && !p.fullscreen); + } + + #[test] + fn imports_the_nested_layout_with_its_scale() { + let w = "(normal_geometry:(outer_position_pixels:Some((x:200.0,y:100.0)),scale_factor:2.0,inner_size_points:(x:640.0,y:480.0)),fullscreen:true,maximized:false)"; + let text = format!("{{\"window\": r#\"{w}\"#}}"); + let p = eframe_app_ron(&text).unwrap(); + assert_eq!((p.scale, p.position_px, p.normal.size), (2.0, Some([200, 100]), [640.0, 480.0])); + assert_eq!(p.normal.pos, [100.0, 50.0]); + assert!(p.fullscreen); + } + + #[test] + fn missing_position_and_garbage_are_handled() { + let w = "(outer_position_pixels:None,fullscreen:false,maximized:false,inner_size_points:Some((x:300.0,y:200.0)))"; + assert_eq!(eframe_window_settings(w, 1.0).unwrap().position_px, None); + assert!(eframe_window_settings("(", 1.0).is_none()); + assert!(eframe_app_ron("not ron").is_none()); + } + + #[test] + fn minimized_windows_parked_off_screen_have_no_position() { + let w = "(outer_position_pixels:Some((x:-32000.0,y:-32000.0)),fullscreen:false,maximized:false,inner_size_points:Some((x:300.0,y:200.0)))"; + let p = eframe_window_settings(w, 1.0).unwrap(); + assert_eq!((p.position_px, p.normal.size), (None, [300.0, 200.0])); + } + + #[test] + fn imported_placement_lands_in_the_store() { + let mut store = Store::in_memory(); + store.set("main", &eframe_app_ron(FLAT).unwrap()); + assert_eq!(store.get("main").unwrap().position_px, Some([120, -30])); + } + + #[test] + fn json_helpers_read_nested_values() { + let v: Value = serde_json::from_str(r#"{"window":{"size":[800,600],"pos":{"x":1,"y":2},"max":true,"w":3.5}}"#).unwrap(); + assert_eq!(json_pair(&v, &["window", "size"], ["w", "h"]), Some([800.0, 600.0])); + assert_eq!(json_pair(&v, &["window", "pos"], ["x", "y"]), Some([1.0, 2.0])); + assert_eq!(json_bool(&v, &["window", "max"]), Some(true)); + assert_eq!(json_f32(&v, &["window", "w"]), Some(3.5)); + assert_eq!(json_f32(&v, &["window", "nope"]), None); + } +} diff --git a/crates/window/src/restore/mod.rs b/crates/window/src/restore/mod.rs new file mode 100644 index 000000000..2e77433c9 --- /dev/null +++ b/crates/window/src/restore/mod.rs @@ -0,0 +1,30 @@ +//! Window restore: where a window opens and the file that remembers it. No egui. +//! +//! - [`place`]: saved [`Placement`] + connected [`Monitor`]s + app [`Defaults`] → the geometry to +//! open with. Pure; the rules are on its documentation. +//! - [`Store`]: `window.json` beside the app's `config.toml`, one [`Placement`] per window key +//! (`"main"` for the first window). +//! - [`legacy`]: readers for what apps stored before, such as eframe's `"window"` entry in +//! `app.ron`. +//! +//! Keep one [`Store`] per app, shared by every window (for example behind an `Arc>`): +//! each write rewrites the whole file from that store's copy, so two stores in one process +//! overwrite each other. +//! +//! ``` +//! use craft_window::restore::{Defaults, Monitor, MonitorKey, Store, place}; +//! +//! let mut store = Store::in_memory(); +//! let monitors = [Monitor::new(MonitorKey::new("DP-1", [1920, 1080]), [0, 0], [1920, 1080], 1.0)]; +//! let defaults = Defaults::new([1200.0, 800.0], [480.0, 320.0]); +//! let opened = place(store.get("main").as_ref(), &monitors, Some(0), &defaults); +//! store.set("main", &opened); +//! assert_eq!(store.get("main"), Some(opened)); +//! ``` + +pub mod legacy; +mod place; +mod store; + +pub use place::{Defaults, Monitor, MonitorKey, NormalRect, Placement, place}; +pub use store::{FILE_NAME, MAIN, Pending, ReadOnly, SCHEMA_VERSION, Store, write_pending}; diff --git a/crates/window/src/restore/place.rs b/crates/window/src/restore/place.rs new file mode 100644 index 000000000..34a5fa6e6 --- /dev/null +++ b/crates/window/src/restore/place.rs @@ -0,0 +1,538 @@ +//! The model and the pure placement rules: saved geometry plus the connected monitors in, the +//! geometry to open with out. +//! +//! Units. Sizes and `NormalRect`s are logical points at interface zoom 1, so a window saved while the +//! user ran the interface at 150 % does not open smaller each launch. Positions are physical +//! pixels in the desktop's coordinate space (negative for a monitor left of or above the primary). + +use serde::{Deserialize, Serialize}; + +/// How much of the window's top strip must lie on one monitor, in points on each axis, for the +/// user to still be able to grab it. +const BAR: f32 = 64.0; +const MAX_POINTS: f32 = 100_000.0; + +/// A rectangle in logical points at zoom 1: top-left corner and size. (Not `craft_window::Rect`, +/// which is the cascade module's.) +/// +/// `pos` is `Placement::position_px` divided by `Placement::scale`; it is informational, and +/// `[0, 0]` when the position is unknown. +#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)] +pub struct NormalRect { + pub pos: [f32; 2], + pub size: [f32; 2], +} + +/// Identifies a monitor across launches: the name the platform reports and its size in pixels. +/// +/// The size is part of the key because names repeat (`"Built-in Display"`) or change (`DP-1` +/// after a re-plug), and a monitor switched to another resolution is a different place to put a +/// window. A key that no longer matches falls back to the nearest connected monitor. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct MonitorKey { + pub name: String, + pub size_px: [u32; 2], +} + +impl MonitorKey { + pub fn new(name: impl Into, size_px: [u32; 2]) -> Self { + Self { name: name.into(), size_px } + } +} + +/// What to remember about a window, and what [`place`] returns for the app to open it with. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct Placement { + /// The last window state that was neither maximized nor full screen. + pub normal: NormalRect, + /// Outer top-left corner in physical pixels; `None` where the platform does not tell (Wayland). + #[serde(default)] + pub position_px: Option<[i32; 2]>, + /// Physical pixels per point of the monitor the window was on, without interface zoom. + #[serde(default = "one")] + pub scale: f32, + /// The interface zoom factor while saving. Informational: `normal` is already at zoom 1. + #[serde(default = "one")] + pub zoom: f32, + #[serde(default)] + pub maximized: bool, + #[serde(default)] + pub fullscreen: bool, + #[serde(default)] + pub monitor: Option, +} + +impl Placement { + /// A window of `size` points, position unknown, on a monitor of `scale`, not maximized. + pub fn new(size: [f32; 2], scale: f32) -> Self { + make(size, None, scale, 1.0, None) + } +} + +fn one() -> f32 { + 1.0 +} + +/// A connected monitor. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub struct Monitor { + pub key: MonitorKey, + /// Top-left corner in the desktop's pixel space. + pub pos_px: [i32; 2], + pub size_px: [u32; 2], + /// Physical pixels per point. + pub scale: f32, + /// The part not taken by the taskbar, Dock or menu bar: top-left corner and size in pixels. + /// When set, it replaces [`Defaults::screen_room`] for this monitor. + pub work_px: Option<([i32; 2], [u32; 2])>, +} + +impl Monitor { + pub fn new(key: MonitorKey, pos_px: [i32; 2], size_px: [u32; 2], scale: f32) -> Self { + Self { key, pos_px, size_px, scale, work_px: None } + } + + /// Sets the usable area (see [`work_px`](Self::work_px)). + pub fn with_work_px(mut self, pos_px: [i32; 2], size_px: [u32; 2]) -> Self { + self.work_px = Some((pos_px, size_px)); + self + } +} + +/// What the app wants when nothing (usable) is saved, and how the platform behaves. +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub struct Defaults { + /// Size of a first window, in points. + pub size: [f32; 2], + /// The smallest window, in points. Yields to a monitor smaller than this. + pub min: [f32; 2], + /// Points a monitor loses to the taskbar, Dock or menu bar, per axis. + pub screen_room: [f32; 2], + /// Restore full screen. Off by default: most apps open as a window. + pub restore_fullscreen: bool, + /// The platform hides window positions (Wayland): none is applied or returned. + pub no_position: bool, +} + +impl Defaults { + pub fn new(size: [f32; 2], min: [f32; 2]) -> Self { + Self { size, min, screen_room: [0.0, 48.0], restore_fullscreen: false, no_position: false } + } + + pub fn with_screen_room(mut self, room: [f32; 2]) -> Self { + self.screen_room = room; + self + } + + pub fn with_restore_fullscreen(mut self, on: bool) -> Self { + self.restore_fullscreen = on; + self + } + + pub fn with_no_position(mut self, on: bool) -> Self { + self.no_position = on; + self + } +} + +impl Monitor { + fn scale(&self) -> f32 { + if self.scale.is_finite() && self.scale > 0.0 { self.scale } else { 1.0 } + } + + fn points(&self) -> [f32; 2] { + self.size_px.map(|s| s as f32 / self.scale()) + } + + /// Position and size in pixels of the area windows may use. + fn bounds(&self) -> ([i32; 2], [u32; 2]) { + self.work_px.unwrap_or((self.pos_px, self.size_px)) + } + + /// Size in points of the area windows may use: the work area, else the monitor less `room`. + fn usable(&self, room: [f32; 2]) -> [f32; 2] { + let s = self.scale(); + match self.work_px { + Some((_, size)) => size.map(|v| (v as f32 / s).max(1.0)), + None => { + let p = self.points(); + std::array::from_fn(|i| (p[i] - room[i].max(0.0)).max(1.0)) + } + } + } + + fn centre_px(&self) -> [f64; 2] { + std::array::from_fn(|i| f64::from(self.pos_px[i]) + f64::from(self.size_px[i]) / 2.0) + } + + /// `pos` moved the least that puts a window of `size` points fully in the usable area. + fn keep_on(&self, pos: [i32; 2], size: [f32; 2]) -> [i32; 2] { + let (bpos, bsize) = self.bounds(); + std::array::from_fn(|i| { + let lo = i64::from(bpos[i]); + let win = (f64::from(size[i]) * f64::from(self.scale())).round() as i64; + let hi = (lo + i64::from(bsize[i]) - win).max(lo); + i32::try_from(i64::from(pos[i]).clamp(lo, hi)).unwrap_or(bpos[i]) + }) + } + + /// Area in points of the window's top strip (full width, up to 64 pt tall) lying on this + /// monitor, if at least min(width, 64) × min(height, 64) pt of it does. A few pixels of slack + /// absorb Windows' snapped offset of about -7 px. + fn bar_overlap(&self, pos: [i32; 2], size: [f32; 2]) -> Option { + const SLACK_PX: f64 = 8.0; + let s = f64::from(self.scale()); + let strip = [size[0], size[1].min(BAR)]; + let need = [size[0].min(BAR), size[1].min(BAR)]; + let mut got = [0.0f64; 2]; + for i in 0..2 { + let a0 = f64::from(pos[i]); + let a1 = a0 + f64::from(strip[i]) * s; + let b0 = f64::from(self.pos_px[i]); + let b1 = b0 + f64::from(self.size_px[i]); + got[i] = (a1.min(b1) - a0.max(b0)).max(0.0); + } + (0..2).all(|i| got[i] + SLACK_PX >= f64::from(need[i]) * s).then(|| (got[0] / s * got[1] / s) as f32) + } +} + +/// Size no bigger than `room` and no smaller than `min` unless `room` is. +fn fit(size: [f32; 2], room: [f32; 2], min: [f32; 2]) -> [f32; 2] { + std::array::from_fn(|i| size[i].min(room[i]).max(min[i].min(room[i])).max(1.0)) +} + +fn in_points(v: f32) -> bool { + v.is_finite() && (1.0..=MAX_POINTS).contains(&v) +} + +/// The saved record with out-of-range numbers repaired, or `None` when its size is no size. +pub(super) fn sane(s: &Placement) -> Option { + if !s.normal.size.iter().all(|v| in_points(*v)) { + return None; + } + let mut s = s.clone(); + if !s.normal.pos.iter().all(|v| v.is_finite()) { + s.normal.pos = [0.0; 2]; + } + if !(s.scale.is_finite() && s.scale > 0.0 && s.scale <= 16.0) { + s.scale = 1.0; + } + if !(s.zoom.is_finite() && (0.1..=10.0).contains(&s.zoom)) { + s.zoom = 1.0; + } + Some(s) +} + +fn make(size: [f32; 2], pos_px: Option<[i32; 2]>, scale: f32, zoom: f32, monitor: Option) -> Placement { + let pos = pos_px.map_or([0.0, 0.0], |p| p.map(|v| v as f32 / scale)); + Placement { normal: NormalRect { pos, size }, position_px: pos_px, scale, zoom, maximized: false, fullscreen: false, monitor } +} + +/// Where a window opens: `saved` kept on a connected monitor, else the default size on `home` +/// (index into `monitors` of the monitor the system opens it on; the one at the origin, else the +/// first, when `None`). +/// +/// - Nothing saved: [`Defaults::size`] fitted to the usable area of `home`; maximized when the +/// monitor is too small for it. The position is left to the system. +/// - The saved size is points at zoom 1 and is not scaled by `zoom`. +/// - `normal` is kept as saved while `maximized`; only the flag says it was maximized. +/// - Off screen: when less than 64×64 pt of the window's top strip lies on any one monitor, the +/// window moves to its saved monitor, else the one nearest its centre, else the primary. +/// - The size is clamped to the usable area of the monitor it lands on and to `min`. +/// - A different `scale` than saved is taken from the monitor; the size stays in points. +/// - [`Defaults::no_position`]: no position in, none out. +/// - Full screen comes back only with [`Defaults::restore_fullscreen`]. +/// - Without monitors (the platform lists none) nothing can be checked: the saved geometry is kept, +/// with the size raised to `min`. +pub fn place(saved: Option<&Placement>, monitors: &[Monitor], home: Option, d: &Defaults) -> Placement { + let saved = saved.and_then(sane); + let primary = monitors.iter().find(|m| m.pos_px == [0, 0]).or(monitors.first()); + let home = home.and_then(|i| monitors.get(i)).or(primary); + match (saved, home) { + (None, None) => make(fit(d.size, [MAX_POINTS; 2], d.min), None, 1.0, 1.0, None), + (None, Some(h)) => first_launch(h, d), + (Some(s), None) => { + let size = fit(s.normal.size, [MAX_POINTS; 2], d.min); + let pos = if d.no_position { None } else { s.position_px }; + Placement { maximized: s.maximized, fullscreen: s.fullscreen && d.restore_fullscreen, ..make(size, pos, s.scale, s.zoom, s.monitor) } + } + (Some(s), Some(h)) => restore(&s, monitors, h, primary.unwrap_or(h), d), + } +} + +fn first_launch(home: &Monitor, d: &Defaults) -> Placement { + let usable = home.usable(d.screen_room); + let too_small = (0..2).any(|i| d.size[i] > usable[i]); + let size = fit(d.size, usable, d.min); + Placement { maximized: too_small, ..make(size, None, home.scale(), 1.0, Some(home.key.clone())) } +} + +fn restore(s: &Placement, monitors: &[Monitor], home: &Monitor, primary: &Monitor, d: &Defaults) -> Placement { + let size = s.normal.size; + let saved_pos = if d.no_position { None } else { s.position_px }; + let by_key = || s.monitor.as_ref().and_then(|k| monitors.iter().find(|m| &m.key == k)); + + let mut visible = false; + let target = match saved_pos { + Some(p) => { + // On mixed-DPI desktops pixel ranges overlap; the saved monitor decides first. + let shown = monitors + .iter() + .filter_map(|m| m.bar_overlap(p, size).map(|a| (a, m))) + .max_by(|a, b| { + let key = |m: &Monitor| s.monitor.as_ref() == Some(&m.key); + key(a.1).cmp(&key(b.1)).then(a.0.total_cmp(&b.0)) + }) + .map(|(_, m)| m); + visible = shown.is_some(); + shown.or_else(by_key).unwrap_or_else(|| nearest(monitors, p, size, s.scale).unwrap_or(primary)) + } + None => by_key().unwrap_or(home), + }; + + let fitted = fit(size, target.usable(d.screen_room), d.min); + let kept = visible && fitted == size; + let pos = saved_pos.map(|p| if kept { p } else { target.keep_on(p, fitted) }); + // A kept position on the saved monitor keeps its saved scale: the pixel ranges of mixed-DPI + // monitors overlap, and the position was measured at that scale. + let scale = if kept && s.monitor.as_ref() == Some(&target.key) { s.scale } else { target.scale() }; + Placement { + maximized: s.maximized, + fullscreen: s.fullscreen && d.restore_fullscreen, + ..make(fitted, pos, scale, s.zoom, Some(target.key.clone())) + } +} + +/// The monitor whose centre is closest to the window's centre. +fn nearest(monitors: &[Monitor], pos: [i32; 2], size: [f32; 2], scale: f32) -> Option<&Monitor> { + let c: [f64; 2] = std::array::from_fn(|i| f64::from(pos[i]) + f64::from(size[i]) * f64::from(scale) / 2.0); + let dist = |m: &Monitor| { + let mc = m.centre_px(); + (mc[0] - c[0]).powi(2) + (mc[1] - c[1]).powi(2) + }; + monitors.iter().min_by(|a, b| dist(a).total_cmp(&dist(b))) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + fn mon(name: &str, pos: [i32; 2], size: [u32; 2], scale: f32) -> Monitor { + Monitor::new(MonitorKey::new(name, size), pos, size, scale) + } + + fn hd() -> Monitor { + mon("A", [0, 0], [1920, 1080], 1.0) + } + + fn defaults() -> Defaults { + let mut d = Defaults::new([1200.0, 800.0], [400.0, 300.0]); + d.screen_room = [0.0, 40.0]; + d + } + + fn saved(pos: Option<[i32; 2]>, size: [f32; 2], scale: f32, m: &Monitor) -> Placement { + let mut p = make(size, pos, scale, 1.0, Some(m.key.clone())); + p.maximized = false; + p + } + + #[test] + fn first_launch_uses_default_size_and_leaves_position_to_the_system() { + let p = place(None, &[hd()], Some(0), &defaults()); + assert_eq!(p.normal.size, [1200.0, 800.0]); + assert_eq!(p.position_px, None); + assert!(!p.maximized); + } + + #[test] + fn first_launch_on_a_small_monitor_fits_and_maximizes() { + let small = mon("S", [0, 0], [1280, 720], 1.0); + let p = place(None, &[small], None, &defaults()); + assert_eq!(p.normal.size, [1200.0, 680.0]); + assert!(p.maximized); + } + + #[test] + fn monitor_gone_moves_the_window_to_a_connected_one() { + let gone = mon("Gone", [1920, 0], [1920, 1080], 1.0); + let s = saved(Some([2500, 100]), [800.0, 600.0], 1.0, &gone); + let p = place(Some(&s), &[hd()], Some(0), &defaults()); + assert_eq!(p.monitor, Some(hd().key)); + let [x, y] = p.position_px.unwrap(); + assert!((0..=1920 - 800).contains(&x) && (0..=1080 - 600).contains(&y)); + } + + #[test] + fn window_dragged_off_screen_comes_back_but_a_visible_bar_stays() { + let m = hd(); + let off = saved(Some([1900, 100]), [800.0, 600.0], 1.0, &m); + let p = place(Some(&off), std::slice::from_ref(&m), Some(0), &defaults()); + assert_eq!(p.position_px, Some([1120, 100])); + // 100 pt of the bar still on the monitor: untouched. + let ok = saved(Some([1820, 100]), [800.0, 600.0], 1.0, &m); + assert_eq!(place(Some(&ok), &[m], Some(0), &defaults()).position_px, Some([1820, 100])); + } + + #[test] + fn mixed_dpi_prefers_the_saved_monitor_and_keeps_its_scale() { + let mac = mon("Mac", [0, 0], [3024, 1964], 2.0); + let ext = mon("Ext", [1512, 0], [1920, 1080], 1.0); + let s = saved(Some([2000, 100]), [800.0, 600.0], 2.0, &mac); + let p = place(Some(&s), &[mac.clone(), ext], Some(0), &defaults()); + assert_eq!((p.monitor, p.scale, p.position_px), (Some(mac.key), 2.0, Some([2000, 100]))); + } + + #[test] + fn bar_is_a_strip_not_a_corner() { + let m = hd(); + // Left edge: 100 px of an 800 pt window show on the right of x = -700; the strip is wide enough. + let s = saved(Some([-700, 100]), [800.0, 600.0], 1.0, &m); + assert_eq!(place(Some(&s), std::slice::from_ref(&m), Some(0), &defaults()).position_px, Some([-700, 100])); + // Only 30 px show: moved back. + let s = saved(Some([-770, 100]), [800.0, 600.0], 1.0, &m); + assert_eq!(place(Some(&s), std::slice::from_ref(&m), Some(0), &defaults()).position_px, Some([0, 100])); + // Windows' snapped -7 px offset on the left or top is fine. + let s = saved(Some([-7, -7]), [800.0, 600.0], 1.0, &m); + assert_eq!(place(Some(&s), std::slice::from_ref(&m), Some(0), &defaults()).position_px, Some([-7, -7])); + // Top edge: the strip is 64 pt tall; 40 pt of it visible is not enough. + let s = saved(Some([100, -24]), [800.0, 600.0], 1.0, &m); + assert_eq!(place(Some(&s), std::slice::from_ref(&m), Some(0), &defaults()).position_px, Some([100, 0])); + } + + #[test] + fn work_area_replaces_screen_room() { + let m = hd().with_work_px([0, 0], [1920, 1000]); + let p = place(None, &[m], Some(0), &Defaults::new([1200.0, 1100.0], [400.0, 300.0])); + assert_eq!(p.normal.size, [1200.0, 1000.0]); + assert!(p.maximized); + } + + #[test] + fn bar_above_the_top_edge_counts_as_off_screen() { + let m = hd(); + let s = saved(Some([100, -50]), [800.0, 600.0], 1.0, &m); + assert_eq!(place(Some(&s), &[m], Some(0), &defaults()).position_px, Some([100, 0])); + } + + #[test] + fn scale_change_keeps_points_and_takes_the_new_scale() { + let old = hd(); + let hidpi = mon("B", [0, 0], [3840, 2160], 2.0); + let s = saved(Some([200, 100]), [1000.0, 700.0], 1.0, &old); + let p = place(Some(&s), std::slice::from_ref(&hidpi), Some(0), &defaults()); + assert_eq!(p.normal.size, [1000.0, 700.0]); + assert_eq!(p.scale, 2.0); + assert_eq!(p.monitor, Some(hidpi.key)); + assert_eq!(p.position_px, Some([200, 100])); + assert_eq!(p.normal.pos, [100.0, 50.0]); + } + + #[test] + fn smaller_monitor_clamps_to_usable_area_and_min() { + let big = mon("Big", [0, 0], [3840, 2160], 1.0); + let s = saved(Some([100, 100]), [3000.0, 1800.0], 1.0, &big); + let small = mon("S", [0, 0], [1280, 720], 1.0); + let p = place(Some(&s), std::slice::from_ref(&small), Some(0), &defaults()); + assert_eq!(p.normal.size, [1280.0, 680.0]); + assert_eq!(p.position_px, Some([0, 40])); + // A monitor below `min`: the monitor wins. + let tiny = mon("T", [0, 0], [320, 240], 1.0); + let p = place(Some(&s), &[tiny], Some(0), &defaults()); + assert_eq!(p.normal.size, [320.0, 200.0]); + // `min` raises a sliver saved size. + let sliver = saved(None, [50.0, 50.0], 1.0, &small); + assert_eq!(place(Some(&sliver), &[small], Some(0), &defaults()).normal.size, [400.0, 300.0]); + } + + #[test] + fn negative_origin_monitors_are_valid_places() { + let left = mon("L", [-1920, 0], [1920, 1080], 1.0); + let above = mon("U", [0, -1080], [1920, 1080], 1.0); + let ms = [hd(), left.clone(), above.clone()]; + let s = saved(Some([-1500, 100]), [800.0, 600.0], 1.0, &left); + assert_eq!(place(Some(&s), &ms, Some(0), &defaults()).position_px, Some([-1500, 100])); + let s = saved(Some([300, -900]), [800.0, 600.0], 1.0, &above); + assert_eq!(place(Some(&s), &ms, Some(0), &defaults()).position_px, Some([300, -900])); + // The left monitor is unplugged: back onto the primary, not stuck at a negative x. + let s = saved(Some([-1500, 100]), [800.0, 600.0], 1.0, &left); + assert_eq!(place(Some(&s), &[hd()], Some(0), &defaults()).position_px, Some([0, 100])); + } + + #[test] + fn nearest_monitor_wins_when_the_saved_one_is_gone() { + let right = mon("R", [1920, 0], [1920, 1080], 1.0); + let far = mon("F", [5000, 0], [1920, 1080], 1.0); + let gone = mon("Gone", [3840, 0], [1920, 1080], 1.0); + let s = saved(Some([6000, 100]), [800.0, 600.0], 1.0, &gone); + let p = place(Some(&s), &[hd(), right, far.clone()], Some(0), &defaults()); + assert_eq!(p.monitor, Some(far.key)); + } + + #[test] + fn wayland_neither_reads_nor_returns_a_position() { + let mut d = defaults(); + d.no_position = true; + let m = hd(); + let s = saved(Some([5000, 5000]), [800.0, 600.0], 1.0, &m); + let p = place(Some(&s), std::slice::from_ref(&m), Some(0), &d); + assert_eq!(p.position_px, None); + assert_eq!(p.normal.size, [800.0, 600.0]); + assert_eq!(place(None, &[m], Some(0), &d).position_px, None); + } + + #[test] + fn size_saved_at_zoom_one_does_not_shrink_with_zoom() { + let m = hd(); + let mut s = saved(Some([100, 100]), [1000.0, 700.0], 1.0, &m); + s.zoom = 1.5; + let p = place(Some(&s), &[m], Some(0), &defaults()); + assert_eq!(p.normal.size, [1000.0, 700.0]); + assert_eq!(p.zoom, 1.5); + } + + #[test] + fn maximized_keeps_the_normal_rect() { + let m = hd(); + let mut s = saved(Some([100, 100]), [900.0, 600.0], 1.0, &m); + s.maximized = true; + let p = place(Some(&s), &[m], Some(0), &defaults()); + assert!(p.maximized); + assert_eq!((p.normal.size, p.position_px), ([900.0, 600.0], Some([100, 100]))); + } + + #[test] + fn full_screen_needs_the_opt_in() { + let m = hd(); + let mut s = saved(Some([100, 100]), [900.0, 600.0], 1.0, &m); + s.fullscreen = true; + let mut d = defaults(); + assert!(!place(Some(&s), std::slice::from_ref(&m), Some(0), &d).fullscreen); + d.restore_fullscreen = true; + assert!(place(Some(&s), &[m], Some(0), &d).fullscreen); + } + + #[test] + fn nonsense_saved_numbers_fall_back_to_a_first_launch() { + let m = hd(); + let mut s = saved(Some([0, 0]), [f32::NAN, 600.0], 1.0, &m); + assert_eq!(place(Some(&s), std::slice::from_ref(&m), Some(0), &defaults()).normal.size, [1200.0, 800.0]); + s.normal.size = [800.0, 600.0]; + s.scale = 0.0; + s.zoom = 99.0; + let p = place(Some(&s), &[m], Some(0), &defaults()); + assert_eq!((p.scale, p.zoom), (1.0, 1.0)); + } + + #[test] + fn no_monitors_keeps_the_saved_geometry() { + let m = hd(); + let s = saved(Some([10, 20]), [800.0, 600.0], 1.0, &m); + let p = place(Some(&s), &[], None, &defaults()); + assert_eq!((p.normal.size, p.position_px), ([800.0, 600.0], Some([10, 20]))); + } +} diff --git a/crates/window/src/restore/store.rs b/crates/window/src/restore/store.rs new file mode 100644 index 000000000..2e184231a --- /dev/null +++ b/crates/window/src/restore/store.rs @@ -0,0 +1,414 @@ +//! `window.json`: the saved [`Placement`] of each window, beside the app's `config.toml`. +//! +//! ```json +//! { "schema_version": 1, "windows": { "main": { ... }, "": { ... } } } +//! ``` +//! +//! The folder is chosen by `craft_settings::paths`, so portable installs keep the file in the +//! portable folder and `_CONFIG_DIR` moves it with `config.toml`. Writes are atomic and +//! private. A file that does not parse is moved aside (`window.broken-.json`, the +//! `craft_settings::recovery` convention) and the store starts empty. A file with a newer +//! `schema_version` is read but never written. Keys this version does not know are written back +//! unchanged. Two running instances of one app overwrite each other; the last writer wins. + +use std::collections::BTreeMap; +use std::io; +use std::path::{Path, PathBuf}; + +use craft_atomic::{NewFilePermissions, WriteOptions}; +use craft_settings::paths::{self, Location, Portable, Probe}; +use craft_settings::recovery; +use serde_json::{Map, Value, json}; + +use super::place::{Placement, sane}; + +/// The file name beside `config.toml`. +pub const FILE_NAME: &str = "window.json"; +/// The `schema_version` this code writes. +pub const SCHEMA_VERSION: u64 = 1; +/// The key of the app's first window. +pub const MAIN: &str = "main"; + +/// Why a store does not write. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ReadOnly { + /// The file has a newer `schema_version` than this code knows. + NewerSchema(u64), + /// The file exists but could not be read (permissions, say); it is left alone. + Unreadable, +} + +/// A write [`Store::take_pending`] prepared. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Pending { + pub path: PathBuf, + pub bytes: Vec, + /// Pass to [`Store::mark_saved`] after the write succeeded. + pub generation: u64, +} + +/// Writes `pending` atomically and privately, creating the folder. Needs no access to the store. +pub fn write_pending(pending: &Pending) -> io::Result<()> { + if let Some(dir) = pending.path.parent() { + std::fs::create_dir_all(dir)?; + } + WriteOptions::default().with_new_file_permissions(NewFilePermissions::Private).atomic_write(&pending.path, &pending.bytes) +} + +/// The saved placements of an app's windows. +#[derive(Debug)] +pub struct Store { + path: Option, + /// Entries stay raw so one that fails to parse is neither lost nor fatal to the others. + windows: BTreeMap, + /// Top-level keys other than `schema_version` and `windows`. + extra: Map, + read_only: Option, + /// Counts changes; the store is dirty while it is ahead of `saved`. + generation: u64, + saved: u64, + moved_aside: Option, +} + +impl Store { + /// The store for `app_id` in the folder `craft_settings` picks for its `config.toml`, with + /// the suite's default portable marker. In an isolated or in-memory configuration + /// (`_CONFIG_DIR` empty) nothing is read or written. + pub fn open(app_id: &str) -> Self { + Self::open_with(app_id, &Portable::DEFAULT, &Location::Auto, &Probe::default()) + } + + /// [`open`](Self::open) with the app's own portable names, location override and probe: the + /// same arguments its `craft_settings` options use. + pub fn open_with(app_id: &str, portable: &Portable, location: &Location, probe: &Probe) -> Self { + match paths::resolve(app_id, portable, location, probe).dir { + Some(dir) => Self::at(&dir.join(FILE_NAME)), + None => Self::in_memory(), + } + } + + /// A store backed by the file at `path`. A missing file is an empty store. + pub fn at(path: &Path) -> Self { + let mut store = Self { path: Some(path.to_path_buf()), ..Self::in_memory() }; + store.load(); + store + } + + /// A store that reads and writes nothing: tests, agents, the web. + pub fn in_memory() -> Self { + Self { path: None, windows: BTreeMap::new(), extra: Map::new(), read_only: None, generation: 0, saved: 0, moved_aside: None } + } + + /// The file this store writes, if it has one. + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } + + /// Where a damaged file was moved to while opening, if one was. + pub fn moved_aside(&self) -> Option<&Path> { + self.moved_aside.as_deref() + } + + /// Why [`save`](Self::save) writes nothing, if so. + pub fn read_only(&self) -> Option<&ReadOnly> { + self.read_only.as_ref() + } + + /// The placement saved under `key`; `None` when absent or not a placement. + pub fn get(&self, key: &str) -> Option { + serde_json::from_value(self.windows.get(key)?.clone()).ok() + } + + /// Replaces the placement under `key`. Fields of the old entry that this version does not know + /// stay. A placement with a non-finite size is ignored. Nothing is written until + /// [`save`](Self::save) or [`take_pending`](Self::take_pending). + pub fn set(&mut self, key: &str, placement: &Placement) { + let Some(placement) = sane(placement) else { return }; + let Ok(Value::Object(new)) = serde_json::to_value(&placement) else { return }; + let merged = match self.windows.get(key) { + Some(Value::Object(old)) => { + let mut m = old.clone(); + m.extend(new); + m + } + _ => new, + }; + let merged = Value::Object(merged); + if self.windows.get(key) != Some(&merged) { + self.windows.insert(key.to_owned(), merged); + self.generation += 1; + } + } + + /// Forgets `key`. + pub fn remove(&mut self, key: &str) { + if self.windows.remove(key).is_some() { + self.generation += 1; + } + } + + /// The keys with an entry, in order. + pub fn keys(&self) -> impl Iterator { + self.windows.keys().map(String::as_str) + } + + /// Whether something changed since the last successful write. + pub fn is_dirty(&self) -> bool { + self.generation != self.saved + } + + /// The write that would bring the file up to date, or `None` when there is nothing to write + /// (in memory, read-only, or unchanged). Write it with [`write_pending`], without holding any + /// lock on the store, then report success with [`mark_saved`](Self::mark_saved). + pub fn take_pending(&self) -> Option { + let path = self.path.clone().filter(|_| self.read_only.is_none() && self.is_dirty())?; + let mut doc = self.extra.clone(); + doc.insert("schema_version".into(), json!(SCHEMA_VERSION)); + doc.insert("windows".into(), Value::Object(self.windows.clone().into_iter().collect())); + let mut bytes = serde_json::to_vec_pretty(&Value::Object(doc)).ok()?; + bytes.push(b'\n'); + Some(Pending { path, bytes, generation: self.generation }) + } + + /// Records that [`Pending::generation`] reached the disk. The store stays dirty if it changed + /// again meanwhile, and after a failed write (do not call this then), so the next + /// [`take_pending`](Self::take_pending) retries. + pub fn mark_saved(&mut self, generation: u64) { + self.saved = self.saved.max(generation.min(self.generation)); + } + + /// [`take_pending`](Self::take_pending), [`write_pending`] and [`mark_saved`](Self::mark_saved) + /// in one call. `Ok(true)` when written; `Ok(false)` when there was nothing to write. + pub fn save(&mut self) -> io::Result { + let Some(pending) = self.take_pending() else { return Ok(false) }; + write_pending(&pending)?; + self.mark_saved(pending.generation); + Ok(true) + } + + fn load(&mut self) { + let Some(path) = self.path.clone() else { return }; + let text = match std::fs::read_to_string(&path) { + Ok(t) => t, + Err(e) if e.kind() == io::ErrorKind::NotFound => return, + // Invalid UTF-8 is damage; anything else may be transient, so leave the file be. + Err(e) if e.kind() == io::ErrorKind::InvalidData => return self.start_over(&path), + Err(_) => { + self.read_only = Some(ReadOnly::Unreadable); + return; + } + }; + let Some(mut doc) = serde_json::from_str::(&text).ok().and_then(|v| match v { + Value::Object(m) => Some(m), + _ => None, + }) else { + return self.start_over(&path); + }; + let Some(version) = doc.remove("schema_version").and_then(|v| v.as_u64()) else { + return self.start_over(&path); + }; + // A newer file is never judged by this version's shape rules, so never moved aside. + if version > SCHEMA_VERSION { + self.read_only = Some(ReadOnly::NewerSchema(version)); + if let Some(Value::Object(m)) = doc.remove("windows") { + self.windows = m.into_iter().collect(); + } + self.extra = doc; + return; + } + let Some(Value::Object(windows)) = doc.remove("windows") else { + return self.start_over(&path); + }; + self.windows = windows.into_iter().collect(); + self.extra = doc; + } + + fn start_over(&mut self, path: &Path) { + match recovery::move_aside(path) { + Ok(to) => self.moved_aside = Some(to), + // Cannot rename it away: do not overwrite what we could not set aside. + Err(_) => self.read_only = Some(ReadOnly::Unreadable), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::restore::{Monitor, MonitorKey}; + + fn sample(x: i32) -> Placement { + let m = Monitor::new(MonitorKey::new("DP-1", [1920, 1080]), [0, 0], [1920, 1080], 1.0); + let mut p = crate::restore::place(None, &[m], Some(0), &crate::restore::Defaults::new([800.0, 600.0], [300.0, 200.0])); + p.position_px = Some([x, 10]); + p + } + + fn siblings(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + names + } + + #[test] + fn round_trip_through_the_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested").join(FILE_NAME); + let mut s = Store::at(&path); + assert!(s.get(MAIN).is_none()); + s.set(MAIN, &sample(5)); + s.set("doc-2", &sample(6)); + assert!(s.save().unwrap()); + assert!(!s.save().unwrap(), "nothing changed"); + let again = Store::at(&path); + assert_eq!(again.get(MAIN), Some(sample(5))); + assert_eq!(again.get("doc-2"), Some(sample(6))); + assert_eq!(again.keys().collect::>(), ["doc-2", MAIN]); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(v["schema_version"], 1); + } + + #[cfg(unix)] + #[test] + fn the_file_is_private() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(FILE_NAME); + let mut s = Store::at(&path); + s.set(MAIN, &sample(1)); + s.save().unwrap(); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o077, 0); + } + + #[test] + fn corrupt_file_is_moved_aside_and_the_store_starts_empty() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(FILE_NAME); + std::fs::write(&path, "{ not json").unwrap(); + let mut s = Store::at(&path); + assert!(s.get(MAIN).is_none() && s.read_only().is_none()); + let aside = s.moved_aside().unwrap().to_path_buf(); + assert_eq!(std::fs::read_to_string(aside).unwrap(), "{ not json"); + assert!(!path.exists()); + s.set(MAIN, &sample(1)); + s.save().unwrap(); + assert!(Store::at(&path).get(MAIN).is_some()); + assert_eq!(siblings(dir.path()).len(), 2); + } + + #[test] + fn json_without_a_schema_version_counts_as_corrupt() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(FILE_NAME); + std::fs::write(&path, r#"{"windows":{}}"#).unwrap(); + assert!(Store::at(&path).moved_aside().is_some()); + } + + #[test] + fn newer_schema_is_read_but_never_written() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(FILE_NAME); + let mut doc = json!({ "schema_version": 2, "windows": {}, "future": [1] }); + doc["windows"][MAIN] = serde_json::to_value(sample(7)).unwrap(); + let text = serde_json::to_string(&doc).unwrap(); + std::fs::write(&path, &text).unwrap(); + let mut s = Store::at(&path); + assert_eq!(s.read_only(), Some(&ReadOnly::NewerSchema(2))); + assert_eq!(s.get(MAIN), Some(sample(7))); + s.set(MAIN, &sample(8)); + assert!(!s.save().unwrap()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), text); + assert_eq!(siblings(dir.path()), [FILE_NAME]); + } + + #[test] + fn unknown_keys_survive_a_write() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(FILE_NAME); + let mut doc = json!({ "schema_version": 1, "note": {"a": 1}, "windows": { "odd": {"unexpected": true} } }); + doc["windows"][MAIN] = serde_json::to_value(sample(1)).unwrap(); + std::fs::write(&path, doc.to_string()).unwrap(); + let mut s = Store::at(&path); + assert!(s.get("odd").is_none()); + s.set(MAIN, &sample(2)); + s.save().unwrap(); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(v["note"], json!({"a": 1})); + assert_eq!(v["windows"]["odd"], json!({"unexpected": true})); + assert_eq!(Store::at(&path).get(MAIN), Some(sample(2))); + } + + #[test] + fn newer_schema_with_an_unexpected_shape_is_not_moved_aside() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(FILE_NAME); + std::fs::write(&path, r#"{"schema_version":3,"windows":[1,2]}"#).unwrap(); + let s = Store::at(&path); + assert_eq!(s.read_only(), Some(&ReadOnly::NewerSchema(3))); + assert!(s.moved_aside().is_none() && path.exists()); + } + + #[test] + fn non_finite_placements_are_never_stored() { + let mut s = Store::in_memory(); + let mut p = sample(1); + p.normal.size[0] = f32::NAN; + s.set(MAIN, &p); + assert!(s.get(MAIN).is_none() && !s.is_dirty()); + let mut p = sample(1); + p.normal.pos[1] = f32::INFINITY; + s.set(MAIN, &p); + assert_eq!(s.get(MAIN).unwrap().normal.pos, [0.0, 0.0]); + } + + #[test] + fn set_keeps_unknown_fields_of_the_entry() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(FILE_NAME); + let mut entry = serde_json::to_value(sample(1)).unwrap(); + entry["later"] = json!("x"); + std::fs::write(&path, json!({"schema_version": 1, "windows": {MAIN: entry}}).to_string()).unwrap(); + let mut s = Store::at(&path); + s.set(MAIN, &sample(2)); + s.save().unwrap(); + let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(v["windows"][MAIN]["later"], "x"); + assert_eq!(Store::at(&path).get(MAIN), Some(sample(2))); + } + + #[test] + fn dirty_clears_only_after_the_write_is_reported() { + let dir = tempfile::tempdir().unwrap(); + let mut s = Store::at(&dir.path().join(FILE_NAME)); + s.set(MAIN, &sample(1)); + let first = s.take_pending().unwrap(); + s.set(MAIN, &sample(2)); + write_pending(&first).unwrap(); + s.mark_saved(first.generation); + assert!(s.is_dirty(), "changed again while the first write was in flight"); + let second = s.take_pending().unwrap(); + assert!(s.is_dirty(), "taking a write does not clear it"); + write_pending(&second).unwrap(); + s.mark_saved(second.generation); + assert!(!s.is_dirty() && s.take_pending().is_none()); + } + + #[test] + fn in_memory_store_keeps_values_and_writes_nothing() { + let mut s = Store::in_memory(); + s.set(MAIN, &sample(3)); + assert_eq!(s.get(MAIN), Some(sample(3))); + assert!(!s.save().unwrap()); + assert!(s.path().is_none()); + } + + #[test] + fn open_puts_the_file_beside_config_toml() { + let dir = tempfile::tempdir().unwrap(); + let s = Store::open_with("app", &Portable::DEFAULT, &Location::Dir(dir.path().to_path_buf()), &Probe::default()); + assert_eq!(s.path(), Some(dir.path().join(FILE_NAME).as_path())); + let mem = Store::open_with("app", &Portable::DEFAULT, &Location::Memory, &Probe::default()); + assert!(mem.path().is_none()); + } +}