Skip to main content

tauri_plugin_window_system/
registry.rs

1use serde::{Deserialize, Serialize};
2use std::collections::{HashMap, HashSet};
3use std::fs;
4use std::path::PathBuf;
5use std::sync::{mpsc, Arc, Mutex, Weak};
6use std::thread;
7use std::time::Duration;
8
9const GEOMETRY_FLUSH_DEBOUNCE: Duration = Duration::from_millis(80);
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum WindowSystemErrorKind {
13    InvalidLabel,
14    WindowAlreadyExists,
15    WindowCannotBeItsOwnParent,
16    ParentWindowNotFound,
17    WindowNotFound,
18    WindowReservationNotFound,
19    WindowRegistryLockPoisoned,
20    WindowStateStoreLockPoisoned,
21}
22
23impl WindowSystemErrorKind {
24    pub fn as_code(self) -> &'static str {
25        match self {
26            Self::InvalidLabel => "invalid-label",
27            Self::WindowAlreadyExists => "window-already-exists",
28            Self::WindowCannotBeItsOwnParent => "window-cannot-be-its-own-parent",
29            Self::ParentWindowNotFound => "parent-window-not-found",
30            Self::WindowNotFound => "window-not-found",
31            Self::WindowReservationNotFound => "window-reservation-not-found",
32            Self::WindowRegistryLockPoisoned => "window-registry-lock-poisoned",
33            Self::WindowStateStoreLockPoisoned => "window-state-store-lock-poisoned",
34        }
35    }
36}
37
38pub fn window_system_error(kind: WindowSystemErrorKind, message: impl Into<String>) -> String {
39    format!("{}: {}", kind.as_code(), message.into())
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct WindowGeometry {
44    pub x: f64,
45    pub y: f64,
46    pub width: f64,
47    pub height: f64,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct WindowDescriptor {
52    pub label: String,
53    pub url: String,
54    pub parent: Option<String>,
55    pub title: Option<String>,
56    pub geometry: Option<WindowGeometry>,
57}
58
59#[derive(Debug, Default, Clone, Serialize, Deserialize)]
60struct WindowStateSnapshot {
61    windows: HashMap<String, WindowGeometry>,
62    tracked_windows: HashMap<String, WindowDescriptor>,
63}
64
65#[derive(Default)]
66struct WindowRegistryState {
67    windows: HashMap<String, WindowDescriptor>,
68    children: HashMap<String, HashSet<String>>,
69    reserved: HashSet<String>,
70    closing: HashSet<String>,
71}
72
73#[derive(Clone)]
74pub struct WindowStateStore {
75    inner: Arc<WindowStateStoreInner>,
76}
77
78struct WindowStateStoreInner {
79    path: PathBuf,
80    snapshot: Mutex<WindowStateSnapshot>,
81    flush_signal: mpsc::SyncSender<()>,
82}
83
84impl WindowStateStore {
85    pub fn new(path: PathBuf) -> Self {
86        let snapshot = load_snapshot(&path).unwrap_or_default();
87        let (flush_signal, flush_receiver) = mpsc::sync_channel(1);
88
89        let inner = Arc::new(WindowStateStoreInner {
90            path,
91            snapshot: Mutex::new(snapshot),
92            flush_signal,
93        });
94
95        spawn_geometry_flusher(Arc::downgrade(&inner), flush_receiver);
96
97        Self { inner }
98    }
99
100    pub fn restore(&self, label: &str) -> Option<WindowGeometry> {
101        self.inner
102            .snapshot
103            .lock()
104            .ok()
105            .and_then(|snapshot| snapshot.windows.get(label).cloned())
106    }
107
108    pub fn restore_tracked_windows(&self) -> Vec<WindowDescriptor> {
109        self.inner
110            .snapshot
111            .lock()
112            .ok()
113            .map(|snapshot| {
114                let mut windows: Vec<_> = snapshot
115                    .tracked_windows
116                    .values()
117                    .cloned()
118                    .map(|mut descriptor| {
119                        if descriptor.geometry.is_none() {
120                            descriptor.geometry = snapshot.windows.get(&descriptor.label).cloned();
121                        }
122                        descriptor
123                    })
124                    .collect();
125                windows.sort_by(|left, right| left.label.cmp(&right.label));
126                windows
127            })
128            .unwrap_or_default()
129    }
130
131    pub fn remember(&self, label: &str, geometry: WindowGeometry) -> Result<(), String> {
132        let mut snapshot = self.inner.snapshot.lock().map_err(|_| {
133            window_system_error(
134                WindowSystemErrorKind::WindowStateStoreLockPoisoned,
135                "window state store lock poisoned",
136            )
137        })?;
138        if snapshot.windows.get(label) == Some(&geometry) {
139            return Ok(());
140        }
141
142        snapshot.windows.insert(label.to_string(), geometry);
143        drop(snapshot);
144        self.schedule_flush();
145        Ok(())
146    }
147
148    pub fn remember_window(&self, descriptor: WindowDescriptor) -> Result<(), String> {
149        let mut snapshot = self.inner.snapshot.lock().map_err(|_| {
150            window_system_error(
151                WindowSystemErrorKind::WindowStateStoreLockPoisoned,
152                "window state store lock poisoned",
153            )
154        })?;
155
156        if snapshot.tracked_windows.get(&descriptor.label) == Some(&descriptor) {
157            return Ok(());
158        }
159
160        snapshot
161            .tracked_windows
162            .insert(descriptor.label.clone(), descriptor);
163        drop(snapshot);
164        self.schedule_flush();
165        Ok(())
166    }
167
168    pub fn forget(&self, label: &str) -> Result<(), String> {
169        let mut snapshot = self.inner.snapshot.lock().map_err(|_| {
170            window_system_error(
171                WindowSystemErrorKind::WindowStateStoreLockPoisoned,
172                "window state store lock poisoned",
173            )
174        })?;
175        if snapshot.windows.remove(label).is_none() {
176            return Ok(());
177        }
178
179        drop(snapshot);
180        self.schedule_flush();
181        Ok(())
182    }
183
184    pub fn forget_window(&self, label: &str) -> Result<(), String> {
185        let mut snapshot = self.inner.snapshot.lock().map_err(|_| {
186            window_system_error(
187                WindowSystemErrorKind::WindowStateStoreLockPoisoned,
188                "window state store lock poisoned",
189            )
190        })?;
191
192        if snapshot.tracked_windows.remove(label).is_some() {
193            drop(snapshot);
194            self.schedule_flush();
195        }
196
197        Ok(())
198    }
199
200    /// Removes every persisted record for a window label.
201    ///
202    /// Use this only for startup cleanup of stale records. Normal close flows keep
203    /// geometry so windows can reopen with their last known size and position.
204    pub fn purge_window(&self, label: &str) -> Result<bool, String> {
205        let mut snapshot = self.inner.snapshot.lock().map_err(|_| {
206            window_system_error(
207                WindowSystemErrorKind::WindowStateStoreLockPoisoned,
208                "window state store lock poisoned",
209            )
210        })?;
211
212        let removed_geometry = snapshot.windows.remove(label).is_some();
213        let removed_descriptor = snapshot.tracked_windows.remove(label).is_some();
214
215        if !removed_geometry && !removed_descriptor {
216            return Ok(false);
217        }
218
219        drop(snapshot);
220        self.schedule_flush();
221        Ok(true)
222    }
223
224    fn schedule_flush(&self) {
225        let _ = self.inner.flush_signal.try_send(());
226    }
227
228    pub fn flush(&self) -> Result<(), String> {
229        self.inner.flush_snapshot()
230    }
231}
232
233impl WindowStateStoreInner {
234    fn flush_snapshot(&self) -> Result<(), String> {
235        let snapshot = self
236            .snapshot
237            .lock()
238            .map_err(|_| {
239                window_system_error(
240                    WindowSystemErrorKind::WindowStateStoreLockPoisoned,
241                    "window state store lock poisoned",
242                )
243            })?
244            .clone();
245
246        persist_snapshot(&self.path, &snapshot)
247    }
248}
249
250impl Drop for WindowStateStoreInner {
251    fn drop(&mut self) {
252        if let Err(err) = self.flush_snapshot() {
253            eprintln!("window-system: final geometry flush failed: {err}");
254        }
255    }
256}
257
258#[derive(Clone)]
259pub struct WindowRegistry {
260    inner: Arc<WindowRegistryInner>,
261}
262
263struct WindowRegistryInner {
264    state: Mutex<WindowRegistryState>,
265    state_store: WindowStateStore,
266}
267
268pub struct WindowReservation {
269    registry: WindowRegistry,
270    label: String,
271    committed: bool,
272}
273
274impl WindowReservation {
275    pub fn commit(mut self) {
276        self.committed = true;
277    }
278}
279
280impl Drop for WindowReservation {
281    fn drop(&mut self) {
282        if !self.committed {
283            self.registry.release_reservation(&self.label);
284        }
285    }
286}
287
288pub struct ClosingGuard {
289    registry: WindowRegistry,
290    label: String,
291}
292
293impl Drop for ClosingGuard {
294    fn drop(&mut self) {
295        self.registry.finish_closing(&self.label);
296    }
297}
298
299impl WindowRegistry {
300    pub fn new(state_store: WindowStateStore) -> Self {
301        Self {
302            inner: Arc::new(WindowRegistryInner {
303                state: Mutex::new(WindowRegistryState::default()),
304                state_store,
305            }),
306        }
307    }
308
309    pub fn reserve_window(
310        &self,
311        label: &str,
312        parent: Option<&str>,
313    ) -> Result<WindowReservation, String> {
314        let mut state = self.inner.state.lock().map_err(|_| {
315            window_system_error(
316                WindowSystemErrorKind::WindowRegistryLockPoisoned,
317                "window registry lock poisoned",
318            )
319        })?;
320
321        if label.trim().is_empty() {
322            return Err(window_system_error(
323                WindowSystemErrorKind::InvalidLabel,
324                "label is required",
325            ));
326        }
327
328        if state.windows.contains_key(label)
329            || state.reserved.contains(label)
330            || state.closing.contains(label)
331        {
332            return Err(window_system_error(
333                WindowSystemErrorKind::WindowAlreadyExists,
334                "window already exists",
335            ));
336        }
337
338        if let Some(parent) = parent {
339            if parent == label {
340                return Err(window_system_error(
341                    WindowSystemErrorKind::WindowCannotBeItsOwnParent,
342                    "window cannot be its own parent",
343                ));
344            }
345
346            if !state.windows.contains_key(parent) || state.closing.contains(parent) {
347                return Err(window_system_error(
348                    WindowSystemErrorKind::ParentWindowNotFound,
349                    format!("parent window not found: {parent}"),
350                ));
351            }
352        }
353
354        state.reserved.insert(label.to_string());
355
356        Ok(WindowReservation {
357            registry: self.clone(),
358            label: label.to_string(),
359            committed: false,
360        })
361    }
362
363    pub fn insert_reserved(&self, descriptor: WindowDescriptor) -> Result<(), String> {
364        let persisted_descriptor = descriptor.clone();
365        let mut state = self.inner.state.lock().map_err(|_| {
366            window_system_error(
367                WindowSystemErrorKind::WindowRegistryLockPoisoned,
368                "window registry lock poisoned",
369            )
370        })?;
371
372        if !state.reserved.remove(&descriptor.label) {
373            return Err(window_system_error(
374                WindowSystemErrorKind::WindowReservationNotFound,
375                "window reservation not found",
376            ));
377        }
378
379        state.update_window(descriptor);
380        drop(state);
381        self.inner
382            .state_store
383            .remember_window(persisted_descriptor)?;
384        Ok(())
385    }
386
387    /// Upserts a live window snapshot during bootstrap or runtime resync.
388    ///
389    /// Repeated syncs are expected, so identical descriptors short-circuit without
390    /// touching the persisted snapshot again.
391    pub fn upsert_live_window(&self, descriptor: WindowDescriptor) -> Result<bool, String> {
392        let persisted_descriptor = descriptor.clone();
393        let mut state = self.inner.state.lock().map_err(|_| {
394            window_system_error(
395                WindowSystemErrorKind::WindowRegistryLockPoisoned,
396                "window registry lock poisoned",
397            )
398        })?;
399
400        if state.windows.get(&descriptor.label) == Some(&descriptor) {
401            return Ok(false);
402        }
403
404        state.update_window(descriptor);
405        drop(state);
406        self.inner
407            .state_store
408            .remember_window(persisted_descriptor)?;
409        Ok(true)
410    }
411
412    pub fn insert(&self, descriptor: WindowDescriptor) -> Result<(), String> {
413        self.upsert_live_window(descriptor).map(|_| ())
414    }
415
416    pub fn remove(&self, label: &str) -> Result<Option<WindowDescriptor>, String> {
417        let mut state = self.inner.state.lock().map_err(|_| {
418            window_system_error(
419                WindowSystemErrorKind::WindowRegistryLockPoisoned,
420                "window registry lock poisoned",
421            )
422        })?;
423        let removed = state.remove_window(label);
424        drop(state);
425        if removed.is_some() {
426            self.inner.state_store.forget_window(label)?;
427        }
428        Ok(removed)
429    }
430
431    pub fn get(&self, label: &str) -> Result<Option<WindowDescriptor>, String> {
432        let state = self.inner.state.lock().map_err(|_| {
433            window_system_error(
434                WindowSystemErrorKind::WindowRegistryLockPoisoned,
435                "window registry lock poisoned",
436            )
437        })?;
438        Ok(state.windows.get(label).cloned())
439    }
440
441    pub fn list(&self) -> Result<Vec<WindowDescriptor>, String> {
442        let state = self.inner.state.lock().map_err(|_| {
443            window_system_error(
444                WindowSystemErrorKind::WindowRegistryLockPoisoned,
445                "window registry lock poisoned",
446            )
447        })?;
448        let mut list: Vec<_> = state.windows.values().cloned().collect();
449        list.sort_by(|left, right| left.label.cmp(&right.label));
450        Ok(list)
451    }
452
453    // Close cascade only needs labels, so keep this path minimal and index-backed.
454    pub fn child_labels_of(&self, parent: &str) -> Result<Vec<String>, String> {
455        let state = self.inner.state.lock().map_err(|_| {
456            window_system_error(
457                WindowSystemErrorKind::WindowRegistryLockPoisoned,
458                "window registry lock poisoned",
459            )
460        })?;
461        let mut labels: Vec<_> = state
462            .children
463            .get(parent)
464            .into_iter()
465            .flat_map(|children| children.iter().cloned())
466            .collect();
467        labels.sort();
468        Ok(labels)
469    }
470
471    // Display and diagnostics want full descriptors, so expand labels only here.
472    pub fn children_of(&self, parent: &str) -> Result<Vec<WindowDescriptor>, String> {
473        let labels = self.child_labels_of(parent)?;
474        let mut list = Vec::with_capacity(labels.len());
475
476        for label in labels {
477            if let Some(descriptor) = self.get(&label)? {
478                list.push(descriptor);
479            }
480        }
481
482        list.sort_by(|left, right| left.label.cmp(&right.label));
483        Ok(list)
484    }
485
486    pub fn restore_geometry(&self, label: &str) -> Option<WindowGeometry> {
487        self.inner.state_store.restore(label)
488    }
489
490    pub fn flush(&self) -> Result<(), String> {
491        self.inner.state_store.flush()
492    }
493
494    pub fn restore_tracked_windows(&self) -> Vec<WindowDescriptor> {
495        self.inner.state_store.restore_tracked_windows()
496    }
497
498    /// Removes all persisted state for a label.
499    ///
500    /// This is reserved for startup cleanup paths where a tracked window can no longer
501    /// be restored and should not survive into the next launch.
502    pub fn purge_persisted_window(&self, label: &str) -> Result<bool, String> {
503        self.inner.state_store.purge_window(label)
504    }
505
506    pub fn remember_geometry(&self, label: &str, geometry: WindowGeometry) -> Result<bool, String> {
507        let persisted_descriptor = {
508            let mut state = self.inner.state.lock().map_err(|_| {
509                window_system_error(
510                    WindowSystemErrorKind::WindowRegistryLockPoisoned,
511                    "window registry lock poisoned",
512                )
513            })?;
514
515            let Some(descriptor) = state.windows.get_mut(label) else {
516                return Ok(false);
517            };
518
519            if descriptor.geometry.as_ref() == Some(&geometry) {
520                return Ok(false);
521            }
522
523            descriptor.geometry = Some(geometry.clone());
524            descriptor.clone()
525        };
526
527        self.inner.state_store.remember(label, geometry)?;
528        self.inner
529            .state_store
530            .remember_window(persisted_descriptor)?;
531        Ok(true)
532    }
533
534    pub fn begin_closing(&self, label: &str) -> Result<Option<ClosingGuard>, String> {
535        let mut state = self.inner.state.lock().map_err(|_| {
536            window_system_error(
537                WindowSystemErrorKind::WindowRegistryLockPoisoned,
538                "window registry lock poisoned",
539            )
540        })?;
541
542        if state.closing.contains(label) {
543            return Ok(None);
544        }
545
546        state.closing.insert(label.to_string());
547        Ok(Some(ClosingGuard {
548            registry: self.clone(),
549            label: label.to_string(),
550        }))
551    }
552
553    fn release_reservation(&self, label: &str) {
554        if let Ok(mut state) = self.inner.state.lock() {
555            state.reserved.remove(label);
556        }
557    }
558
559    fn finish_closing(&self, label: &str) {
560        if let Ok(mut state) = self.inner.state.lock() {
561            state.closing.remove(label);
562        }
563    }
564}
565
566impl WindowRegistryState {
567    fn update_window(&mut self, descriptor: WindowDescriptor) {
568        let label = descriptor.label.clone();
569        let parent = descriptor.parent.clone();
570
571        if let Some(previous) = self.windows.insert(label.clone(), descriptor) {
572            self.detach_child(&previous.label, previous.parent.as_deref());
573        }
574
575        self.attach_child(&label, parent.as_deref());
576    }
577
578    fn remove_window(&mut self, label: &str) -> Option<WindowDescriptor> {
579        let descriptor = self.windows.remove(label)?;
580        self.detach_child(&descriptor.label, descriptor.parent.as_deref());
581        Some(descriptor)
582    }
583
584    fn attach_child(&mut self, label: &str, parent: Option<&str>) {
585        let Some(parent) = parent else {
586            return;
587        };
588
589        self.children
590            .entry(parent.to_string())
591            .or_default()
592            .insert(label.to_string());
593    }
594
595    fn detach_child(&mut self, label: &str, parent: Option<&str>) {
596        let Some(parent) = parent else {
597            return;
598        };
599
600        if let Some(children) = self.children.get_mut(parent) {
601            children.remove(label);
602            if children.is_empty() {
603                self.children.remove(parent);
604            }
605        }
606    }
607}
608
609fn load_snapshot(path: &PathBuf) -> Result<WindowStateSnapshot, String> {
610    if !path.exists() {
611        return Ok(WindowStateSnapshot::default());
612    }
613
614    let raw = fs::read_to_string(path).map_err(|err| err.to_string())?;
615    serde_json::from_str(&raw).map_err(|err| err.to_string())
616}
617
618fn persist_snapshot(path: &PathBuf, snapshot: &WindowStateSnapshot) -> Result<(), String> {
619    if let Some(parent) = path.parent() {
620        fs::create_dir_all(parent).map_err(|err| err.to_string())?;
621    }
622
623    let raw = serde_json::to_string_pretty(snapshot).map_err(|err| err.to_string())?;
624    fs::write(path, raw).map_err(|err| err.to_string())
625}
626
627fn spawn_geometry_flusher(inner: Weak<WindowStateStoreInner>, flush_receiver: mpsc::Receiver<()>) {
628    thread::spawn(move || {
629        while flush_receiver.recv().is_ok() {
630            loop {
631                match flush_receiver.recv_timeout(GEOMETRY_FLUSH_DEBOUNCE) {
632                    Ok(_) => continue,
633                    Err(mpsc::RecvTimeoutError::Timeout) => {
634                        if let Some(inner) = inner.upgrade() {
635                            if let Err(err) = inner.flush_snapshot() {
636                                eprintln!(
637                                    "window-system: failed to persist window geometry snapshot: {err}"
638                                );
639                            }
640                        } else {
641                            break;
642                        }
643                        break;
644                    }
645                    Err(mpsc::RecvTimeoutError::Disconnected) => {
646                        if let Some(inner) = inner.upgrade() {
647                            if let Err(err) = inner.flush_snapshot() {
648                                eprintln!(
649                                    "window-system: failed to persist window geometry snapshot: {err}"
650                                );
651                            }
652                        }
653                        return;
654                    }
655                }
656            }
657        }
658    });
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664    use std::time::{SystemTime, UNIX_EPOCH};
665
666    fn unique_temp_path(name: &str) -> PathBuf {
667        let suffix = SystemTime::now()
668            .duration_since(UNIX_EPOCH)
669            .expect("system clock should be monotonic for tests")
670            .as_nanos();
671        std::env::temp_dir().join(format!("tauri-window-system-{name}-{suffix}.json"))
672    }
673
674    #[test]
675    fn remember_and_restore_geometry() {
676        let store = WindowStateStore::new(unique_temp_path("geometry"));
677        let geometry = WindowGeometry {
678            x: 10.0,
679            y: 20.0,
680            width: 300.0,
681            height: 200.0,
682        };
683
684        store
685            .remember("main", geometry.clone())
686            .expect("store should persist geometry");
687
688        assert_eq!(store.restore("main"), Some(geometry));
689    }
690
691    #[test]
692    fn remember_and_restore_tracked_window() {
693        let path = unique_temp_path("tracked-window");
694        let store = WindowStateStore::new(path.clone());
695        let descriptor = WindowDescriptor {
696            label: "main".into(),
697            url: "index.html".into(),
698            parent: None,
699            title: Some("Main".into()),
700            geometry: Some(WindowGeometry {
701                x: 10.0,
702                y: 20.0,
703                width: 300.0,
704                height: 200.0,
705            }),
706        };
707
708        store
709            .remember_window(descriptor.clone())
710            .expect("tracked window should persist");
711        store.flush().expect("snapshot should flush");
712
713        let restored = WindowStateStore::new(path).restore_tracked_windows();
714        assert_eq!(restored, vec![descriptor]);
715    }
716
717    #[test]
718    fn remember_geometry_reports_changes() {
719        let registry =
720            WindowRegistry::new(WindowStateStore::new(unique_temp_path("geometry-change")));
721        let geometry = WindowGeometry {
722            x: 10.0,
723            y: 20.0,
724            width: 300.0,
725            height: 200.0,
726        };
727
728        registry
729            .insert(WindowDescriptor {
730                label: "main".into(),
731                url: "index.html".into(),
732                parent: None,
733                title: None,
734                geometry: None,
735            })
736            .expect("main should insert");
737
738        assert!(registry
739            .remember_geometry("main", geometry.clone())
740            .expect("geometry should be recorded"));
741        assert!(!registry
742            .remember_geometry("main", geometry)
743            .expect("duplicate geometry should be ignored"));
744    }
745
746    #[test]
747    fn error_messages_are_prefixed_with_codes() {
748        let message = window_system_error(
749            WindowSystemErrorKind::WindowAlreadyExists,
750            "window already exists",
751        );
752
753        assert!(message.starts_with("window-already-exists: "));
754    }
755
756    #[test]
757    fn error_kind_codes_are_stable() {
758        let cases = [
759            (WindowSystemErrorKind::InvalidLabel, "invalid-label"),
760            (
761                WindowSystemErrorKind::WindowAlreadyExists,
762                "window-already-exists",
763            ),
764            (
765                WindowSystemErrorKind::WindowCannotBeItsOwnParent,
766                "window-cannot-be-its-own-parent",
767            ),
768            (
769                WindowSystemErrorKind::ParentWindowNotFound,
770                "parent-window-not-found",
771            ),
772            (WindowSystemErrorKind::WindowNotFound, "window-not-found"),
773            (
774                WindowSystemErrorKind::WindowReservationNotFound,
775                "window-reservation-not-found",
776            ),
777            (
778                WindowSystemErrorKind::WindowRegistryLockPoisoned,
779                "window-registry-lock-poisoned",
780            ),
781            (
782                WindowSystemErrorKind::WindowStateStoreLockPoisoned,
783                "window-state-store-lock-poisoned",
784            ),
785        ];
786
787        for (kind, code) in cases {
788            assert_eq!(kind.as_code(), code);
789        }
790    }
791
792    #[test]
793    fn remember_coalesces_to_latest_persisted_geometry() {
794        let path = unique_temp_path("debounce");
795        let store = WindowStateStore::new(path.clone());
796        let first = WindowGeometry {
797            x: 10.0,
798            y: 20.0,
799            width: 300.0,
800            height: 200.0,
801        };
802        let latest = WindowGeometry {
803            x: 40.0,
804            y: 50.0,
805            width: 640.0,
806            height: 480.0,
807        };
808
809        store
810            .remember("main", first)
811            .expect("first geometry should queue");
812        store
813            .remember("main", latest.clone())
814            .expect("latest geometry should queue");
815
816        for _ in 0..20 {
817            if path.exists() {
818                break;
819            }
820            std::thread::sleep(std::time::Duration::from_millis(25));
821        }
822
823        let raw = std::fs::read_to_string(path).expect("debounced snapshot should be written");
824        let snapshot: WindowStateSnapshot =
825            serde_json::from_str(&raw).expect("snapshot should deserialize");
826
827        assert_eq!(snapshot.windows.get("main"), Some(&latest));
828    }
829
830    #[test]
831    fn flush_writes_snapshot_immediately() {
832        let path = unique_temp_path("flush");
833        let store = WindowStateStore::new(path.clone());
834        let geometry = WindowGeometry {
835            x: 11.0,
836            y: 22.0,
837            width: 33.0,
838            height: 44.0,
839        };
840
841        store
842            .remember("main", geometry.clone())
843            .expect("geometry should queue");
844        store.flush().expect("flush should write immediately");
845
846        let raw = std::fs::read_to_string(path).expect("snapshot should exist");
847        let snapshot: WindowStateSnapshot =
848            serde_json::from_str(&raw).expect("snapshot should deserialize");
849
850        assert_eq!(snapshot.windows.get("main"), Some(&geometry));
851    }
852
853    #[test]
854    fn remove_does_not_clear_geometry() {
855        let registry = WindowRegistry::new(WindowStateStore::new(unique_temp_path("persist")));
856        let geometry = WindowGeometry {
857            x: 1.0,
858            y: 2.0,
859            width: 3.0,
860            height: 4.0,
861        };
862
863        registry
864            .insert(WindowDescriptor {
865                label: "main".into(),
866                url: "index.html".into(),
867                parent: None,
868                title: None,
869                geometry: None,
870            })
871            .expect("main should insert");
872        registry
873            .remember_geometry("main", geometry.clone())
874            .expect("geometry should persist");
875
876        registry.remove("main").expect("remove should work");
877
878        assert_eq!(registry.restore_geometry("main"), Some(geometry));
879    }
880
881    #[test]
882    fn registry_lists_and_filters_children() {
883        let registry = WindowRegistry::new(WindowStateStore::new(unique_temp_path("registry")));
884        registry
885            .insert(WindowDescriptor {
886                label: "main".into(),
887                url: "index.html".into(),
888                parent: None,
889                title: None,
890                geometry: None,
891            })
892            .expect("main should insert");
893        registry
894            .insert(WindowDescriptor {
895                label: "child".into(),
896                url: "child.html".into(),
897                parent: Some("main".into()),
898                title: None,
899                geometry: None,
900            })
901            .expect("child should insert");
902
903        assert_eq!(registry.list().unwrap().len(), 2);
904        assert_eq!(registry.children_of("main").unwrap().len(), 1);
905        assert_eq!(
906            registry.child_labels_of("main").unwrap(),
907            vec!["child".to_string()]
908        );
909    }
910
911    #[test]
912    fn update_reindexes_parent_relationships() {
913        let registry = WindowRegistry::new(WindowStateStore::new(unique_temp_path("reindex")));
914        registry
915            .insert(WindowDescriptor {
916                label: "main-a".into(),
917                url: "main-a.html".into(),
918                parent: None,
919                title: None,
920                geometry: None,
921            })
922            .expect("main-a should insert");
923        registry
924            .insert(WindowDescriptor {
925                label: "main-b".into(),
926                url: "main-b.html".into(),
927                parent: None,
928                title: None,
929                geometry: None,
930            })
931            .expect("main-b should insert");
932        registry
933            .insert(WindowDescriptor {
934                label: "child".into(),
935                url: "child.html".into(),
936                parent: Some("main-a".into()),
937                title: None,
938                geometry: None,
939            })
940            .expect("child should insert");
941
942        registry
943            .insert(WindowDescriptor {
944                label: "child".into(),
945                url: "child.html".into(),
946                parent: Some("main-b".into()),
947                title: None,
948                geometry: None,
949            })
950            .expect("child should reinsert with new parent");
951
952        assert!(registry.child_labels_of("main-a").unwrap().is_empty());
953        assert_eq!(
954            registry.child_labels_of("main-b").unwrap(),
955            vec!["child".to_string()]
956        );
957    }
958
959    #[test]
960    fn upsert_live_window_is_idempotent_for_identical_descriptors() {
961        let registry = WindowRegistry::new(WindowStateStore::new(unique_temp_path("upsert")));
962        let descriptor = WindowDescriptor {
963            label: "main".into(),
964            url: "index.html".into(),
965            parent: None,
966            title: Some("Main".into()),
967            geometry: None,
968        };
969
970        assert!(registry
971            .upsert_live_window(descriptor.clone())
972            .expect("first upsert should insert"));
973        assert!(!registry
974            .upsert_live_window(descriptor)
975            .expect("identical upsert should be ignored"));
976    }
977
978    #[test]
979    fn remove_updates_child_index() {
980        let registry = WindowRegistry::new(WindowStateStore::new(unique_temp_path("children")));
981        registry
982            .insert(WindowDescriptor {
983                label: "main".into(),
984                url: "index.html".into(),
985                parent: None,
986                title: None,
987                geometry: None,
988            })
989            .expect("main should insert");
990        registry
991            .insert(WindowDescriptor {
992                label: "child".into(),
993                url: "child.html".into(),
994                parent: Some("main".into()),
995                title: None,
996                geometry: None,
997            })
998            .expect("child should insert");
999
1000        registry.remove("child").expect("child should remove");
1001
1002        assert!(registry.child_labels_of("main").unwrap().is_empty());
1003        assert!(registry.children_of("main").unwrap().is_empty());
1004    }
1005
1006    #[test]
1007    fn purge_window_removes_tracked_window_and_geometry() {
1008        let path = unique_temp_path("purge");
1009        let store = WindowStateStore::new(path.clone());
1010        let geometry = WindowGeometry {
1011            x: 1.0,
1012            y: 2.0,
1013            width: 3.0,
1014            height: 4.0,
1015        };
1016        let descriptor = WindowDescriptor {
1017            label: "child".into(),
1018            url: "child.html".into(),
1019            parent: Some("main".into()),
1020            title: Some("Child".into()),
1021            geometry: Some(geometry.clone()),
1022        };
1023
1024        store
1025            .remember("child", geometry.clone())
1026            .expect("geometry should persist");
1027        store
1028            .remember_window(descriptor)
1029            .expect("tracked window should persist");
1030        store.flush().expect("snapshot should flush");
1031
1032        assert!(store.purge_window("child").expect("purge should work"));
1033        store.flush().expect("purged snapshot should flush");
1034
1035        let restored_store = WindowStateStore::new(path);
1036        assert!(restored_store.restore_tracked_windows().is_empty());
1037        assert_eq!(restored_store.restore("child"), None);
1038    }
1039
1040    #[test]
1041    fn reservation_rejects_duplicate_labels() {
1042        let registry = WindowRegistry::new(WindowStateStore::new(unique_temp_path("reserve")));
1043        let _reservation = registry
1044            .reserve_window("main", None)
1045            .expect("first reserve");
1046
1047        assert!(registry.reserve_window("main", None).is_err());
1048    }
1049
1050    #[test]
1051    fn reservation_rejects_missing_parent() {
1052        let registry =
1053            WindowRegistry::new(WindowStateStore::new(unique_temp_path("parent-missing")));
1054
1055        assert!(registry.reserve_window("child", Some("main")).is_err());
1056    }
1057
1058    #[test]
1059    fn reservation_rejects_self_parent() {
1060        let registry = WindowRegistry::new(WindowStateStore::new(unique_temp_path("self-parent")));
1061
1062        assert!(registry.reserve_window("main", Some("main")).is_err());
1063    }
1064
1065    #[test]
1066    fn reservation_rejects_closing_parent() {
1067        let registry =
1068            WindowRegistry::new(WindowStateStore::new(unique_temp_path("closing-parent")));
1069        registry
1070            .insert(WindowDescriptor {
1071                label: "main".into(),
1072                url: "index.html".into(),
1073                parent: None,
1074                title: None,
1075                geometry: None,
1076            })
1077            .expect("main should insert");
1078        let guard = registry
1079            .begin_closing("main")
1080            .expect("lock should succeed")
1081            .expect("closing should be marked");
1082
1083        assert!(registry.reserve_window("child", Some("main")).is_err());
1084        drop(guard);
1085    }
1086
1087    #[test]
1088    fn closing_guard_prevents_duplicate_begin() {
1089        let registry = WindowRegistry::new(WindowStateStore::new(unique_temp_path("closing")));
1090        let guard = registry
1091            .begin_closing("main")
1092            .expect("lock should succeed")
1093            .expect("first closing mark");
1094
1095        assert!(registry
1096            .begin_closing("main")
1097            .expect("lock should succeed")
1098            .is_none());
1099
1100        drop(guard);
1101        assert!(registry
1102            .begin_closing("main")
1103            .expect("lock should succeed")
1104            .is_some());
1105    }
1106}