1use 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 #[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
127static STAGING_SEQ: AtomicU64 = AtomicU64::new(0);
128
129fn save_store(dir: &Path, store: &PersistedStore) -> Result<(), String> {
130 std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
131 let body = serde_json::to_string_pretty(store).map_err(|e| format!("serialize: {e}"))?;
132 let seq = STAGING_SEQ.fetch_add(1, Ordering::Relaxed);
135 let tmp = dir.join(format!("{STATE_FILE}.tmp.{}-{seq}", std::process::id()));
136 std::fs::write(&tmp, body).map_err(|e| format!("write {}: {e}", tmp.display()))?;
137 std::fs::rename(&tmp, dir.join(STATE_FILE))
138 .inspect_err(|_| {
139 let _ = std::fs::remove_file(&tmp);
140 })
141 .map_err(|e| format!("rename state: {e}"))
142}
143
144fn distance(ax: f64, ay: f64, bx: f64, by: f64) -> f64 {
145 (ax - bx).powi(2) + (ay - by).powi(2)
146}
147fn distance_to_center(m: &MonitorSnapshot, p: (f64, f64)) -> f64 {
148 distance(m.center().0, m.center().1, p.0, p.1)
149}
150
151pub fn restoreable_state(state: &WindowState, monitors: &[MonitorSnapshot]) -> LogicalRect {
153 let saved_monitor = state.monitor;
154 let w = LogicalRect {
155 x: state.x,
156 y: state.y,
157 width: state.width,
158 height: state.height,
159 };
160 let exact = monitors
161 .iter()
162 .filter(|m| {
163 (m.width - saved_monitor.width).abs() < 0.5
164 && (m.height - saved_monitor.height).abs() < 0.5
165 })
166 .min_by(|a, b| {
167 distance(a.x, a.y, saved_monitor.x, saved_monitor.y).total_cmp(&distance(
168 b.x,
169 b.y,
170 saved_monitor.x,
171 saved_monitor.y,
172 ))
173 });
174 let center = (w.x + w.width / 2.0, w.y + w.height / 2.0);
175 let intersecting = monitors
176 .iter()
177 .filter(|m| {
178 w.x < m.x + m.width
179 && w.x + w.width > m.x
180 && w.y < m.y + m.height
181 && w.y + w.height > m.y
182 })
183 .min_by(|a, b| distance_to_center(a, center).total_cmp(&distance_to_center(b, center)));
184 let target = exact.or(intersecting).copied().unwrap_or_else(|| {
185 *monitors
186 .iter()
187 .min_by(|a, b| {
188 distance_to_center(a, saved_monitor.center())
189 .total_cmp(&distance_to_center(b, saved_monitor.center()))
190 })
191 .expect("non-empty monitors")
192 });
193 let mut x = w.x + target.x - saved_monitor.x;
194 let mut y = w.y + target.y - saved_monitor.y;
195 let width = w.width.min(target.width).max(MIN_WIDTH.min(target.width));
196 let height = w
197 .height
198 .min(target.height)
199 .max(MIN_HEIGHT.min(target.height));
200 let max_x = target.x + target.width - CLAMP_MARGIN_X;
201 let max_y = target.y + target.height - CLAMP_MARGIN_Y;
202 let min_x = target.x - (width - CLAMP_MARGIN_X).max(0.0);
203 x = if min_x <= max_x {
204 x.clamp(min_x, max_x)
205 } else {
206 target.x
207 };
208 y = if target.y <= max_y {
209 y.clamp(target.y, max_y)
210 } else {
211 target.y
212 };
213 LogicalRect {
214 x,
215 y,
216 width,
217 height,
218 }
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum LegacyImport {
224 Imported { entries: usize },
226 AlreadyImported,
228 NoLegacyFile,
230}
231
232pub struct WindowStateStore {
234 dir: PathBuf,
235 generations: Mutex<HashMap<String, Arc<AtomicU64>>>,
236 io: Mutex<()>,
240}
241
242impl WindowStateStore {
243 pub fn new(dir: PathBuf) -> Self {
244 Self {
245 dir,
246 generations: Mutex::new(HashMap::new()),
247 io: Mutex::new(()),
248 }
249 }
250 fn io_lock(&self) -> std::sync::MutexGuard<'_, ()> {
251 self.io.lock().unwrap_or_else(|e| e.into_inner())
252 }
253 pub fn dir(&self) -> &Path {
254 &self.dir
255 }
256 fn generation(&self, label: &str) -> Arc<AtomicU64> {
257 Arc::clone(
258 self.generations
259 .lock()
260 .unwrap_or_else(|e| e.into_inner())
261 .entry(label.to_owned())
262 .or_default(),
263 )
264 }
265
266 pub fn saved(&self, label: &str) -> Option<WindowState> {
268 let _io = self.io_lock();
269 load_store(&self.dir).states.remove(label)
270 }
271
272 pub fn import_legacy(
283 &self,
284 source_id: &str,
285 legacy_file: &Path,
286 label_map: &[(&str, &str)],
287 ) -> Result<LegacyImport, String> {
288 if source_id.is_empty()
289 || !source_id
290 .chars()
291 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
292 {
293 return Err("invalid import source id".into());
294 }
295 let marker = self.dir.join(format!("window-state.imported-{source_id}"));
296 if marker.exists() {
297 return Ok(LegacyImport::AlreadyImported);
298 }
299 let raw = match std::fs::read_to_string(legacy_file) {
300 Ok(raw) => raw,
301 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
302 return Ok(LegacyImport::NoLegacyFile)
303 }
304 Err(e) => return Err(format!("read {}: {e}", legacy_file.display())),
305 };
306 let _io = self.io_lock();
309 if marker.exists() {
310 return Ok(LegacyImport::AlreadyImported);
311 }
312 let legacy: PersistedStore = if raw.trim().is_empty() {
313 PersistedStore::default()
314 } else {
315 serde_json::from_str(&raw)
316 .map(migrate)
317 .map_err(|e| format!("parse {}: {e}", legacy_file.display()))?
318 };
319 let mut store = load_store(&self.dir);
320 let mut imported = 0;
321 for (label, state) in legacy.states {
322 let target = if label_map.is_empty() {
323 label
324 } else {
325 match label_map.iter().find(|(from, _)| *from == label) {
326 Some((_, to)) => (*to).to_owned(),
327 None => continue,
328 }
329 };
330 if store.states.contains_key(&target) {
331 continue;
332 }
333 store.states.insert(target, state);
334 imported += 1;
335 }
336 if imported > 0 {
337 save_store(&self.dir, &store)?;
338 }
339 std::fs::create_dir_all(&self.dir)
340 .map_err(|e| format!("create {}: {e}", self.dir.display()))?;
341 std::fs::write(&marker, b"1").map_err(|e| format!("write {}: {e}", marker.display()))?;
342 Ok(LegacyImport::Imported { entries: imported })
343 }
344
345 pub fn save_state(&self, label: &str, state: WindowState) -> Result<(), String> {
348 let _io = self.io_lock();
349 let mut store = load_store(&self.dir);
350 store.states.insert(label.to_owned(), state);
351 save_store(&self.dir, &store)
352 }
353
354 pub fn restore<R: Runtime>(&self, app: &AppHandle<R>, label: &str) -> Result<bool, String> {
357 self.restore_with(app, label, &RestoreOptions::default())
358 }
359
360 pub fn restore_with<R: Runtime>(
365 &self,
366 app: &AppHandle<R>,
367 label: &str,
368 options: &RestoreOptions,
369 ) -> Result<bool, String> {
370 let Some(window) = app.get_webview_window(label) else {
371 return Ok(false);
372 };
373 let constrained = options.constrained(crate::lifecycle::reveal_mode(
375 app,
376 crate::lifecycle::RevealSource::Restore,
377 ));
378 let options = &constrained;
379 let monitors = window
380 .available_monitors()
381 .map_err(|e| format!("available_monitors: {e}"))?;
382 let snapshots: Vec<_> = monitors.iter().map(MonitorSnapshot::from_monitor).collect();
383 let state = self.saved(label).filter(|_| !snapshots.is_empty());
384 let mut target = WindowTarget(&window);
385 match state {
386 Some(state) => {
387 let rect = restoreable_state(&state, &snapshots);
388 apply_restore(&mut target, rect, &state, options);
389 Ok(true)
390 }
391 None => {
392 apply_visibility(&mut target, options, false);
393 Ok(false)
394 }
395 }
396 }
397
398 pub fn schedule_save<R: Runtime>(self: &Arc<Self>, app: AppHandle<R>, label: String) {
399 let token = self.begin_save(&label);
400 let this = Arc::clone(self);
401 std::thread::spawn(move || {
402 std::thread::sleep(Duration::from_millis(SAVE_DEBOUNCE_MS));
403 if this.generation(&label).load(Ordering::SeqCst) == token {
404 let _ = this.save_generation(&app, &label, token);
405 }
406 });
407 }
408
409 fn begin_save(&self, label: &str) -> u64 {
412 let _io = self.io_lock();
413 self.generation(label).fetch_add(1, Ordering::SeqCst) + 1
414 }
415
416 pub fn save_now<R: Runtime>(&self, app: &AppHandle<R>, label: &str) -> Result<(), String> {
417 self.save_generation(app, label, self.begin_save(label))
418 }
419
420 fn save_generation<R: Runtime>(
421 &self,
422 app: &AppHandle<R>,
423 label: &str,
424 token: u64,
425 ) -> Result<(), String> {
426 let Some(window) = app.get_webview_window(label) else {
427 return Ok(());
428 };
429 if window.is_fullscreen().unwrap_or(false) {
430 return Ok(());
431 }
432 let minimized = window.is_minimized().unwrap_or(false);
433 let scale = window.scale_factor().map_err(|e| format!("scale: {e}"))?;
434 let Some(monitor) = window
435 .current_monitor()
436 .map_err(|e| format!("current_monitor: {e}"))?
437 else {
438 return Ok(());
439 };
440 let monitor = MonitorSnapshot::from_monitor(&monitor);
441 let maximized = window.is_maximized().unwrap_or(false);
442 let geometry = if maximized || minimized {
444 None
445 } else {
446 let p = window
447 .outer_position()
448 .map_err(|e| format!("outer_position: {e}"))?;
449 let s = window
450 .inner_size()
451 .map_err(|e| format!("inner_size: {e}"))?;
452 Some((
453 f64::from(p.x) / scale,
454 f64::from(p.y) / scale,
455 f64::from(s.width) / scale,
456 f64::from(s.height) / scale,
457 ))
458 };
459 self.publish_generation(label, token, monitor, maximized, minimized, geometry)
460 }
461
462 fn publish_generation(
463 &self,
464 label: &str,
465 token: u64,
466 monitor: MonitorSnapshot,
467 maximized: bool,
468 minimized: bool,
469 geometry: Option<(f64, f64, f64, f64)>,
470 ) -> Result<(), String> {
471 let _io = self.io_lock();
472 if self.generation(label).load(Ordering::SeqCst) != token {
473 return Ok(());
474 }
475 let mut store = load_store(&self.dir);
476 let state = next_state(
477 store.states.get(label),
478 monitor,
479 maximized,
480 minimized,
481 geometry,
482 );
483 store.states.insert(label.to_owned(), state);
484 save_store(&self.dir, &store)
485 }
486}
487
488pub fn next_state(
492 prev: Option<&WindowState>,
493 monitor: MonitorSnapshot,
494 maximized: bool,
495 minimized: bool,
496 geometry: Option<(f64, f64, f64, f64)>,
497) -> WindowState {
498 match (maximized || minimized, geometry) {
499 (false, Some((x, y, width, height))) => WindowState {
500 x,
501 y,
502 width,
503 height,
504 maximized: false,
505 minimized: false,
506 monitor,
507 version: CURRENT_VERSION,
508 },
509 _ => {
510 let normal = prev.cloned().unwrap_or_else(|| default_normal(monitor));
511 WindowState {
512 maximized: maximized || (minimized && normal.maximized),
514 minimized,
515 monitor,
516 version: CURRENT_VERSION,
517 ..normal
518 }
519 }
520 }
521}
522
523#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
526pub struct RestoreOptions {
527 pub unminimize: bool,
529 pub show: bool,
531 pub focus: bool,
533 pub restore_minimized: bool,
535}
536
537impl RestoreOptions {
538 pub fn constrained(self, mode: crate::lifecycle::RevealMode) -> Self {
541 use crate::lifecycle::RevealMode;
542 match mode {
543 RevealMode::Foreground => self,
544 RevealMode::Background => Self {
545 unminimize: false,
546 focus: false,
547 ..self
548 },
549 RevealMode::Forbidden => Self {
550 unminimize: false,
551 show: false,
552 focus: false,
553 restore_minimized: false,
554 },
555 }
556 }
557}
558
559pub trait RestoreTarget {
561 fn unmaximize(&mut self);
562 fn set_size(&mut self, width: f64, height: f64);
563 fn set_position(&mut self, x: f64, y: f64);
564 fn maximize(&mut self);
565 fn unminimize(&mut self);
566 fn show(&mut self);
567 fn focus(&mut self);
568 fn minimize(&mut self);
569}
570
571struct WindowTarget<'a, R: Runtime>(&'a tauri::WebviewWindow<R>);
572
573impl<R: Runtime> RestoreTarget for WindowTarget<'_, R> {
574 fn unmaximize(&mut self) {
575 let _ = self.0.unmaximize();
576 }
577 fn set_size(&mut self, width: f64, height: f64) {
578 let _ = self
579 .0
580 .set_size(Size::Logical(LogicalSize { width, height }));
581 }
582 fn set_position(&mut self, x: f64, y: f64) {
583 let _ = self
584 .0
585 .set_position(Position::Logical(LogicalPosition { x, y }));
586 }
587 fn maximize(&mut self) {
588 let _ = self.0.maximize();
589 }
590 fn unminimize(&mut self) {
591 let _ = self.0.unminimize();
592 }
593 fn show(&mut self) {
594 let _ = self.0.show();
595 }
596 fn focus(&mut self) {
597 let _ = self.0.set_focus();
598 }
599 fn minimize(&mut self) {
600 let _ = self.0.minimize();
601 }
602}
603
604pub fn apply_restore<T: RestoreTarget>(
606 target: &mut T,
607 rect: LogicalRect,
608 state: &WindowState,
609 options: &RestoreOptions,
610) {
611 target.unmaximize();
612 target.set_size(rect.width, rect.height);
613 target.set_position(rect.x, rect.y);
614 if state.maximized {
615 target.maximize();
616 }
617 apply_visibility(target, options, state.minimized);
618}
619
620fn apply_visibility<T: RestoreTarget>(
621 target: &mut T,
622 options: &RestoreOptions,
623 was_minimized: bool,
624) {
625 if options.unminimize {
626 target.unminimize();
627 }
628 if options.show {
629 target.show();
630 }
631 if options.focus {
632 target.focus();
633 }
634 if options.restore_minimized && was_minimized {
635 target.minimize();
636 }
637}
638
639#[cfg(test)]
640mod publication_order_tests {
641 use super::*;
642
643 #[test]
644 fn newer_immediate_save_invalidates_paused_same_label_snapshot() {
645 let dir = tempfile::tempdir().unwrap();
646 let store = Arc::new(WindowStateStore::new(dir.path().to_owned()));
647 let (captured_tx, captured_rx) = std::sync::mpsc::channel();
648 let (resume_tx, resume_rx) = std::sync::mpsc::channel();
649 let old_store = Arc::clone(&store);
650 let old = std::thread::spawn(move || {
651 let token = old_store.begin_save("main");
652 let monitor = MonitorSnapshot {
653 x: 0.0,
654 y: 0.0,
655 width: 1920.0,
656 height: 1080.0,
657 scale: 1.0,
658 };
659 captured_tx.send(()).unwrap();
660 resume_rx.recv().unwrap();
661 old_store
662 .publish_generation(
663 "main",
664 token,
665 monitor,
666 false,
667 false,
668 Some((1.0, 2.0, 300.0, 400.0)),
669 )
670 .unwrap();
671 });
672 captured_rx.recv().unwrap();
673 let token = store.begin_save("main");
674 store
675 .publish_generation(
676 "main",
677 token,
678 MonitorSnapshot {
679 x: 0.0,
680 y: 0.0,
681 width: 1920.0,
682 height: 1080.0,
683 scale: 1.0,
684 },
685 false,
686 false,
687 Some((20.0, 30.0, 600.0, 700.0)),
688 )
689 .unwrap();
690 resume_tx.send(()).unwrap();
691 old.join().unwrap();
692 let state = store.saved("main").unwrap();
693 assert_eq!(
694 (state.x, state.y, state.width, state.height),
695 (20.0, 30.0, 600.0, 700.0)
696 );
697 }
698}