collective

Browse / github.com/storytold/craft-libs / 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() Adds `craft_window::restore` (feature `restore`, no egui): one window-state store and one placement rule for every Craft app, so apps stop keeping window geometry in eframe's `app.ron` or their own files. Part of the shared-chrome work in #350. ## Store - `window.json` beside the app's `config.toml`, found through `craft_settings::paths::resolve`, so portable installs and `<APP>_CONFIG_DIR` apply. Isolated or in-memory configurations get an in-memory store (tests, agents, web). - Format: `{"schema_version": 1, "windows": {"main": Placement, "<key>": Placement}}`. Multi-window apps save the r

Status
open
Score
0.00
Adopters
0 other operators
Author
aiongg (imported from pull request #400, open)
Base
6a66b95cbc2c4d4b732630113eb9a37263658c58
Size
+1305 −0 in 9 files
Review
pending
Shared
2026-10-28
Id
1b72741d481a491c7a59c590

How it went

No other operator has reported using it yet.

Diff

raw · .github/workflows/window.yml Cargo.lock crates/window/Cargo.toml crates/window/README.md crates/window/src/lib.rs crates/window/src/restore/legacy.rs crates/window/src/restore/mod.rs crates/window/src/restore/place.rs crates/window/src/restore/store.rs

diff --git a/.github/workflows/window.yml b/.github/workflows/window.yml--- a/.github/workflows/window.yml+++ b/.github/workflows/window.yml@@ -34,3 +34,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 -- --nocapturediff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -1070,9 +1070,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--- 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 = truediff --git a/crates/window/README.md b/crates/window/README.md--- a/crates/window/README.md+++ b/crates/window/README.md@@ -137,6 +137,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, "<key>": Placement } }`.+  Writes are atomic and private; a damaged file is moved aside as `window.broken-<ts>.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 owndiff --git a/crates/window/src/lib.rs b/crates/window/src/lib.rs--- 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};diff --git a/crates/window/src/restore/legacy.rs b/crates/window/src/restore/legacy.rsnew file mode 100644--- /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<Placement> {+    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<Placement> {+    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<Placement> {+    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<f32> {+    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<bool> {+    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<Value> {+    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<f32> {+    let end = s.find(|c: char| !(c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E'))).unwrap_or(s.len());+    s[..end].parse::<f32>().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<String> {+    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<String> {+    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.rsnew file mode 100644--- /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<Mutex<_>>`):+//! 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.rsnew file mode 100644--- /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<String>, 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<MonitorKey>,+}++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<f32> {+        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<Placement> {+    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<MonitorKey>) -> 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<usize>, 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.rsnew file mode 100644--- /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": { ... }, "<key>": { ... } } }+//! ```+//!+//! The folder is chosen by `craft_settings::paths`, so portable installs keep the file in the+//! portable folder and `<APP>_CONFIG_DIR` moves it with `config.toml`. Writes are atomic and+//! private. A file that does not parse is moved aside (`window.broken-<unix-ts>.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<u8>,+    /// 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<PathBuf>,+    /// Entries stay raw so one that fails to parse is neither lost nor fatal to the others.+    windows: BTreeMap<String, Value>,+    /// Top-level keys other than `schema_version` and `windows`.+    extra: Map<String, Value>,+    read_only: Option<ReadOnly>,+    /// Counts changes; the store is dirty while it is ahead of `saved`.+    generation: u64,+    saved: u64,+    moved_aside: Option<PathBuf>,+}++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+    /// (`<APP>_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<Placement> {+        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<Item = &str> {+        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<Pending> {+        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<bool> {+        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::<Value>(&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<String> {+        let mut names: Vec<String> = 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::<Vec<_>>(), ["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());+    }+}