Skip to main content

rightkit_tauri_shell/
window_state.rs

1//! DPI-independent window geometry persistence and restore.
2//!
3//! Base: CodeRight's `window_state` (the most developed donor: logical-pixel storage, v0 -> v1
4//! migration, exact-dimension / intersecting / nearest-monitor selection, on-screen title bar
5//! clamp, maximised-state memory). Changes: no tokio (debounce on a thread), the state file lives
6//! under the shell's per-app directory, and every label is tracked, not only `main`.
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Arc, Mutex};
12use std::time::Duration;
13
14use serde::{Deserialize, Serialize};
15use tauri::{AppHandle, LogicalPosition, LogicalSize, Manager, Monitor, Position, Runtime, Size};
16
17const CURRENT_VERSION: u32 = 1;
18const STATE_FILE: &str = "window-state.json";
19const MIN_WIDTH: f64 = 600.0;
20const MIN_HEIGHT: f64 = 400.0;
21pub(crate) const CLAMP_MARGIN_X: f64 = 80.0;
22pub(crate) const CLAMP_MARGIN_Y: f64 = 40.0;
23const SAVE_DEBOUNCE_MS: u64 = 250;
24
25#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
26pub struct MonitorSnapshot {
27    pub x: f64,
28    pub y: f64,
29    pub width: f64,
30    pub height: f64,
31    pub scale: f64,
32}
33
34impl MonitorSnapshot {
35    pub(crate) fn from_monitor(m: &Monitor) -> Self {
36        let scale = m.scale_factor();
37        let scale = if scale.is_finite() && scale > 0.0 {
38            scale
39        } else {
40            1.0
41        };
42        Self {
43            x: f64::from(m.position().x) / scale,
44            y: f64::from(m.position().y) / scale,
45            width: f64::from(m.size().width) / scale,
46            height: f64::from(m.size().height) / scale,
47            scale,
48        }
49    }
50    fn center(self) -> (f64, f64) {
51        (self.x + self.width / 2.0, self.y + self.height / 2.0)
52    }
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub struct WindowState {
57    pub x: f64,
58    pub y: f64,
59    pub width: f64,
60    pub height: f64,
61    pub maximized: bool,
62    /// The window was minimised when last saved. Its `x`, `y`, `width` and `height` are the last
63    /// normal geometry, never the off-screen rectangle a minimised window reports.
64    #[serde(default)]
65    pub minimized: bool,
66    pub monitor: MonitorSnapshot,
67    #[serde(default)]
68    pub version: u32,
69}
70
71#[derive(Debug, Default, Clone, Serialize, Deserialize)]
72struct PersistedStore {
73    #[serde(flatten)]
74    states: HashMap<String, WindowState>,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq)]
78pub struct LogicalRect {
79    pub x: f64,
80    pub y: f64,
81    pub width: f64,
82    pub height: f64,
83}
84
85fn default_normal(monitor: MonitorSnapshot) -> WindowState {
86    let width = 1200.0_f64.min(monitor.width);
87    let height = 800.0_f64.min(monitor.height);
88    WindowState {
89        x: monitor.x + (monitor.width - width).max(0.0) / 2.0,
90        y: monitor.y + (monitor.height - height).max(0.0) / 2.0,
91        width,
92        height,
93        maximized: false,
94        minimized: false,
95        monitor,
96        version: CURRENT_VERSION,
97    }
98}
99
100fn migrate(mut store: PersistedStore) -> PersistedStore {
101    for s in store.states.values_mut() {
102        if s.version < CURRENT_VERSION {
103            let scale = if s.monitor.scale.is_finite() && s.monitor.scale > 0.0 {
104                s.monitor.scale
105            } else {
106                1.0
107            };
108            s.x /= scale;
109            s.y /= scale;
110            s.width /= scale;
111            s.height /= scale;
112            s.version = CURRENT_VERSION;
113        }
114    }
115    store
116}
117
118fn load_store(dir: &Path) -> PersistedStore {
119    match std::fs::read_to_string(dir.join(STATE_FILE)) {
120        Ok(raw) if !raw.trim().is_empty() => {
121            serde_json::from_str(&raw).map(migrate).unwrap_or_default()
122        }
123        _ => PersistedStore::default(),
124    }
125}
126
127fn save_store(dir: &Path, store: &PersistedStore) -> Result<(), String> {
128    std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
129    let body = serde_json::to_string_pretty(store).map_err(|e| format!("serialize: {e}"))?;
130    let tmp = dir.join(format!("{STATE_FILE}.tmp"));
131    std::fs::write(&tmp, body).map_err(|e| format!("write {}: {e}", tmp.display()))?;
132    std::fs::rename(&tmp, dir.join(STATE_FILE)).map_err(|e| format!("rename state: {e}"))
133}
134
135fn distance(ax: f64, ay: f64, bx: f64, by: f64) -> f64 {
136    (ax - bx).powi(2) + (ay - by).powi(2)
137}
138fn distance_to_center(m: &MonitorSnapshot, p: (f64, f64)) -> f64 {
139    distance(m.center().0, m.center().1, p.0, p.1)
140}
141
142/// Pure geometry projection: where should a saved window land on the current monitors?
143pub fn restoreable_state(state: &WindowState, monitors: &[MonitorSnapshot]) -> LogicalRect {
144    let saved_monitor = state.monitor;
145    let w = LogicalRect {
146        x: state.x,
147        y: state.y,
148        width: state.width,
149        height: state.height,
150    };
151    let exact = monitors
152        .iter()
153        .filter(|m| {
154            (m.width - saved_monitor.width).abs() < 0.5
155                && (m.height - saved_monitor.height).abs() < 0.5
156        })
157        .min_by(|a, b| {
158            distance(a.x, a.y, saved_monitor.x, saved_monitor.y).total_cmp(&distance(
159                b.x,
160                b.y,
161                saved_monitor.x,
162                saved_monitor.y,
163            ))
164        });
165    let center = (w.x + w.width / 2.0, w.y + w.height / 2.0);
166    let intersecting = monitors
167        .iter()
168        .filter(|m| {
169            w.x < m.x + m.width
170                && w.x + w.width > m.x
171                && w.y < m.y + m.height
172                && w.y + w.height > m.y
173        })
174        .min_by(|a, b| distance_to_center(a, center).total_cmp(&distance_to_center(b, center)));
175    let target = exact.or(intersecting).copied().unwrap_or_else(|| {
176        *monitors
177            .iter()
178            .min_by(|a, b| {
179                distance_to_center(a, saved_monitor.center())
180                    .total_cmp(&distance_to_center(b, saved_monitor.center()))
181            })
182            .expect("non-empty monitors")
183    });
184    let mut x = w.x + target.x - saved_monitor.x;
185    let mut y = w.y + target.y - saved_monitor.y;
186    let width = w.width.min(target.width).max(MIN_WIDTH.min(target.width));
187    let height = w
188        .height
189        .min(target.height)
190        .max(MIN_HEIGHT.min(target.height));
191    let max_x = target.x + target.width - CLAMP_MARGIN_X;
192    let max_y = target.y + target.height - CLAMP_MARGIN_Y;
193    let min_x = target.x - (width - CLAMP_MARGIN_X).max(0.0);
194    x = if min_x <= max_x {
195        x.clamp(min_x, max_x)
196    } else {
197        target.x
198    };
199    y = if target.y <= max_y {
200        y.clamp(target.y, max_y)
201    } else {
202        target.y
203    };
204    LogicalRect {
205        x,
206        y,
207        width,
208        height,
209    }
210}
211
212/// Result of [`WindowStateStore::import_legacy`].
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum LegacyImport {
215    /// The legacy file was read; `entries` were added (0 when every label already existed).
216    Imported { entries: usize },
217    /// An earlier call already imported this source.
218    AlreadyImported,
219    /// No legacy file exists (nothing marked; a later call may still import).
220    NoLegacyFile,
221}
222
223/// Per-app store with debounced saves.
224pub struct WindowStateStore {
225    dir: PathBuf,
226    generations: Mutex<HashMap<String, Arc<AtomicU64>>>,
227}
228
229impl WindowStateStore {
230    pub fn new(dir: PathBuf) -> Self {
231        Self {
232            dir,
233            generations: Mutex::new(HashMap::new()),
234        }
235    }
236    pub fn dir(&self) -> &Path {
237        &self.dir
238    }
239    fn generation(&self, label: &str) -> Arc<AtomicU64> {
240        Arc::clone(
241            self.generations
242                .lock()
243                .unwrap_or_else(|e| e.into_inner())
244                .entry(label.to_owned())
245                .or_default(),
246        )
247    }
248
249    /// The state saved for `label`, if any. Reads the file; applies nothing to any window.
250    pub fn saved(&self, label: &str) -> Option<WindowState> {
251        load_store(&self.dir).states.remove(label)
252    }
253
254    /// Import a legacy per-app geometry store once (M1-G5; CodeRight `window_state/mod.rs:92,110-118`
255    /// shape: a flat `label -> state` JSON object, `version` 0 meaning physical pixels).
256    ///
257    /// Each legacy entry runs through the same v0 -> v1 migration the store applies on load, then is
258    /// added under its owner label (`label_map` is `(legacy, owner)` pairs; an empty map keeps every
259    /// label). An owner entry that already exists is never overwritten. Idempotent: a marker file
260    /// `window-state.imported-<source_id>` is written after the first attempt that found the legacy
261    /// file (a missing file is not marked, so a later-appearing file still imports), and later
262    /// calls return [`LegacyImport::AlreadyImported`]. The legacy file is left in place. A legacy
263    /// file that does not parse is reported as an error and not marked.
264    pub fn import_legacy(
265        &self,
266        source_id: &str,
267        legacy_file: &Path,
268        label_map: &[(&str, &str)],
269    ) -> Result<LegacyImport, String> {
270        if source_id.is_empty()
271            || !source_id
272                .chars()
273                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
274        {
275            return Err("invalid import source id".into());
276        }
277        let marker = self.dir.join(format!("window-state.imported-{source_id}"));
278        if marker.exists() {
279            return Ok(LegacyImport::AlreadyImported);
280        }
281        let raw = match std::fs::read_to_string(legacy_file) {
282            Ok(raw) => raw,
283            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
284                return Ok(LegacyImport::NoLegacyFile)
285            }
286            Err(e) => return Err(format!("read {}: {e}", legacy_file.display())),
287        };
288        let legacy: PersistedStore = if raw.trim().is_empty() {
289            PersistedStore::default()
290        } else {
291            serde_json::from_str(&raw)
292                .map(migrate)
293                .map_err(|e| format!("parse {}: {e}", legacy_file.display()))?
294        };
295        let mut store = load_store(&self.dir);
296        let mut imported = 0;
297        for (label, state) in legacy.states {
298            let target = if label_map.is_empty() {
299                label
300            } else {
301                match label_map.iter().find(|(from, _)| *from == label) {
302                    Some((_, to)) => (*to).to_owned(),
303                    None => continue,
304                }
305            };
306            if store.states.contains_key(&target) {
307                continue;
308            }
309            store.states.insert(target, state);
310            imported += 1;
311        }
312        if imported > 0 {
313            save_store(&self.dir, &store)?;
314        }
315        std::fs::create_dir_all(&self.dir)
316            .map_err(|e| format!("create {}: {e}", self.dir.display()))?;
317        std::fs::write(&marker, b"1").map_err(|e| format!("write {}: {e}", marker.display()))?;
318        Ok(LegacyImport::Imported { entries: imported })
319    }
320
321    /// Record `state` for `label` without touching a window (a host that tracks its own geometry,
322    /// or a test).
323    pub fn save_state(&self, label: &str, state: WindowState) -> Result<(), String> {
324        let mut store = load_store(&self.dir);
325        store.states.insert(label.to_owned(), state);
326        save_store(&self.dir, &store)
327    }
328
329    /// Apply saved geometry to `label` (hidden or not). Returns whether a saved state existed.
330    /// Positions only; equal to [`WindowStateStore::restore_with`] with the default options.
331    pub fn restore<R: Runtime>(&self, app: &AppHandle<R>, label: &str) -> Result<bool, String> {
332        self.restore_with(app, label, &RestoreOptions::default())
333    }
334
335    /// [`WindowStateStore::restore`] that can also bring the window up: `options` may unminimise,
336    /// show and focus it, and re-minimise a window saved minimised. The visibility options apply
337    /// whenever the window exists, saved geometry or not (a first run still shows). Returns whether
338    /// saved geometry was applied. Nothing calls this for you outside the full `Shell` plugin.
339    pub fn restore_with<R: Runtime>(
340        &self,
341        app: &AppHandle<R>,
342        label: &str,
343        options: &RestoreOptions,
344    ) -> Result<bool, String> {
345        let Some(window) = app.get_webview_window(label) else {
346            return Ok(false);
347        };
348        // The visibility guard has the last word on unminimise, show and focus.
349        let constrained = options.constrained(crate::lifecycle::reveal_mode(
350            app,
351            crate::lifecycle::RevealSource::Restore,
352        ));
353        let options = &constrained;
354        let monitors = window
355            .available_monitors()
356            .map_err(|e| format!("available_monitors: {e}"))?;
357        let snapshots: Vec<_> = monitors.iter().map(MonitorSnapshot::from_monitor).collect();
358        let state = self.saved(label).filter(|_| !snapshots.is_empty());
359        let mut target = WindowTarget(&window);
360        match state {
361            Some(state) => {
362                let rect = restoreable_state(&state, &snapshots);
363                apply_restore(&mut target, rect, &state, options);
364                Ok(true)
365            }
366            None => {
367                apply_visibility(&mut target, options, false);
368                Ok(false)
369            }
370        }
371    }
372
373    pub fn schedule_save<R: Runtime>(self: &Arc<Self>, app: AppHandle<R>, label: String) {
374        let generation = self.generation(&label);
375        let token = generation.fetch_add(1, Ordering::SeqCst) + 1;
376        let this = Arc::clone(self);
377        std::thread::spawn(move || {
378            std::thread::sleep(Duration::from_millis(SAVE_DEBOUNCE_MS));
379            if this.generation(&label).load(Ordering::SeqCst) == token {
380                let _ = this.save_now(&app, &label);
381            }
382        });
383    }
384
385    pub fn save_now<R: Runtime>(&self, app: &AppHandle<R>, label: &str) -> Result<(), String> {
386        let Some(window) = app.get_webview_window(label) else {
387            return Ok(());
388        };
389        if window.is_fullscreen().unwrap_or(false) {
390            return Ok(());
391        }
392        let minimized = window.is_minimized().unwrap_or(false);
393        let scale = window.scale_factor().map_err(|e| format!("scale: {e}"))?;
394        let Some(monitor) = window
395            .current_monitor()
396            .map_err(|e| format!("current_monitor: {e}"))?
397        else {
398            return Ok(());
399        };
400        let monitor = MonitorSnapshot::from_monitor(&monitor);
401        let maximized = window.is_maximized().unwrap_or(false);
402        // A minimised window reports an off-screen rectangle on some platforms: never read it.
403        let geometry = if maximized || minimized {
404            None
405        } else {
406            let p = window
407                .outer_position()
408                .map_err(|e| format!("outer_position: {e}"))?;
409            let s = window
410                .inner_size()
411                .map_err(|e| format!("inner_size: {e}"))?;
412            Some((
413                f64::from(p.x) / scale,
414                f64::from(p.y) / scale,
415                f64::from(s.width) / scale,
416                f64::from(s.height) / scale,
417            ))
418        };
419        let mut store = load_store(&self.dir);
420        let state = next_state(
421            store.states.get(label),
422            monitor,
423            maximized,
424            minimized,
425            geometry,
426        );
427        store.states.insert(label.to_owned(), state);
428        save_store(&self.dir, &store)
429    }
430}
431
432/// The state to save next. `geometry` is the live `(x, y, width, height)` in logical pixels and is
433/// only read for a window that is neither maximised nor minimised; otherwise the last normal
434/// geometry is kept (or the default rectangle when none was saved).
435pub fn next_state(
436    prev: Option<&WindowState>,
437    monitor: MonitorSnapshot,
438    maximized: bool,
439    minimized: bool,
440    geometry: Option<(f64, f64, f64, f64)>,
441) -> WindowState {
442    match (maximized || minimized, geometry) {
443        (false, Some((x, y, width, height))) => WindowState {
444            x,
445            y,
446            width,
447            height,
448            maximized: false,
449            minimized: false,
450            monitor,
451            version: CURRENT_VERSION,
452        },
453        _ => {
454            let normal = prev.cloned().unwrap_or_else(|| default_normal(monitor));
455            WindowState {
456                // A minimised window may report either; keep what was saved if it was maximised.
457                maximized: maximized || (minimized && normal.maximized),
458                minimized,
459                monitor,
460                version: CURRENT_VERSION,
461                ..normal
462            }
463        }
464    }
465}
466
467/// What [`WindowStateStore::restore_with`] does besides positioning. The default does nothing else,
468/// which is what `restore` and the full plugin have always done.
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
470pub struct RestoreOptions {
471    /// Unminimise the window.
472    pub unminimize: bool,
473    /// Show the window.
474    pub show: bool,
475    /// Give the window focus (raises it; leave off for a window that must not steal focus).
476    pub focus: bool,
477    /// Minimise again a window that was saved minimised. Applied last, after `show`.
478    pub restore_minimized: bool,
479}
480
481impl RestoreOptions {
482    /// These options as the visibility guard allows them: `Forbidden` strips unminimise, show,
483    /// focus and re-minimise; `Background` strips unminimise and focus (QA never raises a window).
484    pub fn constrained(self, mode: crate::lifecycle::RevealMode) -> Self {
485        use crate::lifecycle::RevealMode;
486        match mode {
487            RevealMode::Foreground => self,
488            RevealMode::Background => Self {
489                unminimize: false,
490                focus: false,
491                ..self
492            },
493            RevealMode::Forbidden => Self {
494                unminimize: false,
495                show: false,
496                focus: false,
497                restore_minimized: false,
498            },
499        }
500    }
501}
502
503/// The window calls a restore makes, so the order is testable without a window.
504pub trait RestoreTarget {
505    fn unmaximize(&mut self);
506    fn set_size(&mut self, width: f64, height: f64);
507    fn set_position(&mut self, x: f64, y: f64);
508    fn maximize(&mut self);
509    fn unminimize(&mut self);
510    fn show(&mut self);
511    fn focus(&mut self);
512    fn minimize(&mut self);
513}
514
515struct WindowTarget<'a, R: Runtime>(&'a tauri::WebviewWindow<R>);
516
517impl<R: Runtime> RestoreTarget for WindowTarget<'_, R> {
518    fn unmaximize(&mut self) {
519        let _ = self.0.unmaximize();
520    }
521    fn set_size(&mut self, width: f64, height: f64) {
522        let _ = self
523            .0
524            .set_size(Size::Logical(LogicalSize { width, height }));
525    }
526    fn set_position(&mut self, x: f64, y: f64) {
527        let _ = self
528            .0
529            .set_position(Position::Logical(LogicalPosition { x, y }));
530    }
531    fn maximize(&mut self) {
532        let _ = self.0.maximize();
533    }
534    fn unminimize(&mut self) {
535        let _ = self.0.unminimize();
536    }
537    fn show(&mut self) {
538        let _ = self.0.show();
539    }
540    fn focus(&mut self) {
541        let _ = self.0.set_focus();
542    }
543    fn minimize(&mut self) {
544        let _ = self.0.minimize();
545    }
546}
547
548/// Geometry first (size and position, then maximised), then the requested visibility.
549pub fn apply_restore<T: RestoreTarget>(
550    target: &mut T,
551    rect: LogicalRect,
552    state: &WindowState,
553    options: &RestoreOptions,
554) {
555    target.unmaximize();
556    target.set_size(rect.width, rect.height);
557    target.set_position(rect.x, rect.y);
558    if state.maximized {
559        target.maximize();
560    }
561    apply_visibility(target, options, state.minimized);
562}
563
564fn apply_visibility<T: RestoreTarget>(
565    target: &mut T,
566    options: &RestoreOptions,
567    was_minimized: bool,
568) {
569    if options.unminimize {
570        target.unminimize();
571    }
572    if options.show {
573        target.show();
574    }
575    if options.focus {
576        target.focus();
577    }
578    if options.restore_minimized && was_minimized {
579        target.minimize();
580    }
581}