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
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
142pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum LegacyImport {
215 Imported { entries: usize },
217 AlreadyImported,
219 NoLegacyFile,
221}
222
223pub 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 pub fn saved(&self, label: &str) -> Option<WindowState> {
251 load_store(&self.dir).states.remove(label)
252 }
253
254 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 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 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 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 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 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
432pub 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 maximized: maximized || (minimized && normal.maximized),
458 minimized,
459 monitor,
460 version: CURRENT_VERSION,
461 ..normal
462 }
463 }
464 }
465}
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
470pub struct RestoreOptions {
471 pub unminimize: bool,
473 pub show: bool,
475 pub focus: bool,
477 pub restore_minimized: bool,
479}
480
481impl RestoreOptions {
482 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
503pub 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
548pub 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}