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 -- --nocapture
diff --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 = true
diff --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 own
diff --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.rs
new 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.rs
new 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.rs
new 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.rs
new 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());
+    }
+}
