1use crate::events::{emit_registry_changed, RegistryChangeKind};
2use crate::lifecycle::{
3 apply_window_geometry, attach_native_parent, attach_window_event_handlers,
4 close_window_tree as close_window_tree_impl,
5};
6use crate::message::{
7 broadcast_window_message as broadcast_window_message_impl,
8 send_window_message as send_window_message_impl, BroadcastWindowMessageRequest,
9 SendWindowMessageRequest, WindowMessageDispatchResult,
10};
11use crate::registry::{WindowDescriptor, WindowGeometry, WindowRegistry};
12use crate::sync_live_window;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::collections::HashSet;
16use std::time::Instant;
17use tauri::{
18 command, AppHandle, Manager, Runtime, State, WebviewUrl, WebviewWindowBuilder, Window,
19};
20
21#[derive(Debug, Deserialize)]
22pub struct OpenWindowRequest {
23 pub label: String,
24 pub url: Option<String>,
25 pub parent: Option<String>,
26 pub title: Option<String>,
27 pub geometry: Option<WindowGeometry>,
28}
29
30#[derive(Debug, Clone, Serialize)]
31#[serde(rename_all = "camelCase")]
32pub struct RestoreWindowsResult {
33 pub restored: Vec<WindowDescriptor>,
34 pub already_alive: Vec<String>,
35 pub skipped: Vec<RestoreSkippedWindow>,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct RestoreSkippedWindow {
40 pub label: String,
41 pub parent: Option<String>,
42 pub reason: RestoreSkipReason,
43}
44
45#[derive(Debug, Clone, Copy, Serialize)]
46#[serde(rename_all = "kebab-case")]
47pub enum RestoreSkipReason {
48 MissingParent,
49}
50
51#[derive(Debug)]
52struct NormalizedOpenWindowRequest {
53 label: String,
54 url: String,
55 parent: Option<String>,
56 title: Option<String>,
57 geometry: Option<WindowGeometry>,
58}
59
60#[cfg(any(debug_assertions, feature = "window-timings"))]
61struct OperationTiming {
62 operation: &'static str,
63 subject: Option<String>,
64 started_at: Instant,
65 last_mark: Instant,
66}
67
68#[cfg(not(any(debug_assertions, feature = "window-timings")))]
69struct OperationTiming;
70
71impl OperationTiming {
72 #[cfg(any(debug_assertions, feature = "window-timings"))]
73 fn new(operation: &'static str, subject: Option<&str>) -> Self {
74 let now = Instant::now();
75 Self {
76 operation,
77 subject: subject.map(str::to_string),
78 started_at: now,
79 last_mark: now,
80 }
81 }
82
83 #[cfg(not(any(debug_assertions, feature = "window-timings")))]
84 fn new(_operation: &'static str, _subject: Option<&str>) -> Self {
85 Self
86 }
87
88 #[cfg(any(debug_assertions, feature = "window-timings"))]
89 fn mark(&mut self, stage: &str) {
90 let now = Instant::now();
91 let stage_elapsed = now.duration_since(self.last_mark);
92 let total_elapsed = now.duration_since(self.started_at);
93 log_operation_timing(
94 self.operation,
95 self.subject.as_deref(),
96 stage,
97 stage_elapsed.as_millis(),
98 total_elapsed.as_millis(),
99 );
100 self.last_mark = now;
101 }
102
103 #[cfg(not(any(debug_assertions, feature = "window-timings")))]
104 fn mark(&mut self, _stage: &str) {}
105
106 #[cfg(any(debug_assertions, feature = "window-timings"))]
107 fn finish(self, summary: Option<&str>) {
108 let total_elapsed = Instant::now().duration_since(self.started_at);
109 log_operation_completion(
110 self.operation,
111 self.subject.as_deref(),
112 total_elapsed.as_millis(),
113 summary,
114 );
115 }
116
117 #[cfg(not(any(debug_assertions, feature = "window-timings")))]
118 fn finish(self, _summary: Option<&str>) {}
119}
120
121#[command]
122pub async fn open_window<R: Runtime>(
123 handle: AppHandle<R>,
124 window: Window<R>,
125 registry: State<'_, WindowRegistry>,
126 request: OpenWindowRequest,
127) -> Result<WindowDescriptor, String> {
128 let current_label = window.label();
129 sync_live_window(&handle, &*registry, ¤t_label)?;
130 open_window_impl(&handle, &*registry, request).await
131}
132
133#[command]
134pub async fn restore_windows<R: Runtime>(
135 handle: AppHandle<R>,
136 registry: State<'_, WindowRegistry>,
137) -> Result<RestoreWindowsResult, String> {
138 let mut timing = OperationTiming::new("restore_windows", None);
139
140 let live_labels: HashSet<String> = registry
141 .list()?
142 .into_iter()
143 .map(|descriptor| descriptor.label)
144 .collect();
145
146 let plan = plan_restore_windows(registry.restore_tracked_windows(), live_labels);
147 timing.mark("planned");
148 let mut restored = Vec::new();
149
150 for descriptor in &plan.restore_order {
151 let request = OpenWindowRequest {
152 label: descriptor.label.clone(),
153 url: Some(descriptor.url.clone()),
154 parent: descriptor.parent.clone(),
155 title: descriptor.title.clone(),
156 geometry: descriptor.geometry.clone(),
157 };
158 open_window_impl(&handle, &*registry, request).await?;
159 restored.push(descriptor.clone());
160 }
161 timing.mark("restored");
162 cleanup_stale_restore_windows(&*registry, &plan)?;
163 timing.mark("cleaned-startup-stale");
164
165 let result = RestoreWindowsResult {
166 restored,
167 already_alive: plan.already_alive,
168 skipped: plan.skipped,
169 };
170 timing.finish(Some(&format!(
171 "restored={} already_alive={} skipped={}",
172 result.restored.len(),
173 result.already_alive.len(),
174 result.skipped.len()
175 )));
176
177 Ok(result)
178}
179
180fn cleanup_stale_restore_windows(
181 registry: &WindowRegistry,
182 plan: &RestorePlan,
183) -> Result<(), String> {
184 let mut stale_labels = HashSet::new();
185
186 for window in &plan.skipped {
187 stale_labels.insert(window.label.clone());
188 }
189
190 for window in &plan.already_alive_descriptors {
193 if let Some(parent) = window.parent.as_deref() {
194 if !plan.live_labels.contains(parent) {
195 stale_labels.insert(window.label.clone());
196 }
197 }
198 }
199
200 if stale_labels.is_empty() {
201 return Ok(());
202 }
203
204 for label in stale_labels {
205 match registry.purge_persisted_window(&label) {
206 Ok(true) => {}
207 Ok(false) => {}
208 Err(err) => {
209 eprintln!(
210 "window-system: failed to purge stale window label={} error={err}",
211 label
212 );
213 return Err(err);
214 }
215 }
216 }
217
218 registry.flush()
219}
220
221struct RestorePlan {
222 restore_order: Vec<WindowDescriptor>,
223 already_alive: Vec<String>,
224 already_alive_descriptors: Vec<WindowDescriptor>,
225 skipped: Vec<RestoreSkippedWindow>,
226 live_labels: HashSet<String>,
227}
228
229fn plan_restore_windows(
230 descriptors: Vec<WindowDescriptor>,
231 mut live_labels: HashSet<String>,
232) -> RestorePlan {
233 let mut pending = descriptors;
234 let mut restore_order = Vec::new();
235 let mut already_alive = Vec::new();
236 let mut already_alive_descriptors = Vec::new();
237 let mut skipped = Vec::new();
238
239 while !pending.is_empty() {
240 let mut next_round = Vec::new();
241 let mut progress = false;
242
243 for descriptor in pending {
244 if live_labels.contains(&descriptor.label) {
245 already_alive.push(descriptor.label.clone());
246 already_alive_descriptors.push(descriptor);
247 continue;
248 }
249
250 if let Some(parent) = descriptor.parent.as_deref() {
251 if !live_labels.contains(parent) {
252 next_round.push(descriptor);
253 continue;
254 }
255 }
256
257 live_labels.insert(descriptor.label.clone());
258 restore_order.push(descriptor);
259 progress = true;
260 }
261
262 if !progress {
263 skipped.extend(
264 next_round
265 .into_iter()
266 .map(|descriptor| RestoreSkippedWindow {
267 label: descriptor.label,
268 parent: descriptor.parent,
269 reason: RestoreSkipReason::MissingParent,
270 }),
271 );
272 break;
273 }
274
275 pending = next_round;
276 }
277
278 RestorePlan {
279 restore_order,
280 already_alive,
281 already_alive_descriptors,
282 skipped,
283 live_labels,
284 }
285}
286
287async fn open_window_impl<R: Runtime>(
288 handle: &AppHandle<R>,
289 registry: &WindowRegistry,
290 request: OpenWindowRequest,
291) -> Result<WindowDescriptor, String> {
292 let request = normalize_open_window_request(registry, request)?;
293 let mut timing = OperationTiming::new("open_window", Some(&request.label));
294
295 let reservation = registry.reserve_window(&request.label, request.parent.as_deref())?;
296 timing.mark("reserved");
297
298 let (window, is_reused_window) = build_webview_window(handle, &request)?;
299 timing.mark("built");
300
301 let geometry = request.geometry.clone();
302 if let Some(geometry) = geometry.as_ref() {
303 if let Err(err) = apply_window_geometry(&window, geometry) {
304 if !is_reused_window {
305 let _ = window.destroy();
306 }
307 return Err(err);
308 }
309 }
310 timing.mark("geometry-applied");
311
312 let title = request.title.clone().or_else(|| window.title().ok());
313 let descriptor = WindowDescriptor {
314 label: request.label.clone(),
315 url: request.url.clone(),
316 parent: request.parent.clone(),
317 title,
318 geometry,
319 };
320 if let Err(err) = registry.insert_reserved(descriptor.clone()) {
321 if !is_reused_window {
322 let _ = window.destroy();
323 }
324 return Err(err);
325 }
326 reservation.commit();
327
328 attach_window_event_handlers(handle, registry, &window, &request.label);
329
330 if let Err(err) = reveal_window(&window) {
333 let _ = registry.remove(&request.label);
334 if !is_reused_window {
335 let _ = window.destroy();
336 }
337 return Err(err);
338 }
339 timing.mark(if is_reused_window {
340 "reused-shown"
341 } else {
342 "shown"
343 });
344
345 emit_registry_changed(
346 &handle,
347 ®istry,
348 RegistryChangeKind::Opened,
349 &request.label,
350 )?;
351 timing.mark("emitted");
352 timing.finish(None);
353
354 Ok(descriptor)
355}
356
357fn normalize_open_window_request(
358 registry: &WindowRegistry,
359 request: OpenWindowRequest,
360) -> Result<NormalizedOpenWindowRequest, String> {
361 let label = normalize_required_text(request.label, "label")?;
362 let parent = normalize_optional_text(request.parent);
363 let title = normalize_optional_text(request.title);
364 let url = normalize_window_url(request.url);
365 let geometry = request
366 .geometry
367 .or_else(|| registry.restore_geometry(&label));
368
369 Ok(NormalizedOpenWindowRequest {
370 label,
371 url,
372 parent,
373 title,
374 geometry,
375 })
376}
377
378fn normalize_required_text(value: String, field_name: &str) -> Result<String, String> {
379 let normalized = value.trim();
380 if normalized.is_empty() {
381 return Err(crate::registry::window_system_error(
382 crate::registry::WindowSystemErrorKind::InvalidLabel,
383 format!("{field_name} is required"),
384 ));
385 }
386
387 Ok(normalized.to_string())
388}
389
390fn normalize_optional_text(value: Option<String>) -> Option<String> {
391 value
392 .map(|value| value.trim().to_string())
393 .filter(|value| !value.is_empty())
394}
395
396fn normalize_window_url(url: Option<String>) -> String {
397 match url {
398 Some(url) => {
399 let normalized = url.trim();
400 if normalized.is_empty() {
401 "index.html".to_string()
402 } else {
403 normalized.to_string()
404 }
405 }
406 None => "index.html".to_string(),
407 }
408}
409
410fn build_webview_window<R: Runtime>(
411 handle: &AppHandle<R>,
412 request: &NormalizedOpenWindowRequest,
413) -> Result<(tauri::WebviewWindow<R>, bool), String> {
414 if let Some(window) = handle.get_webview_window(&request.label) {
415 if let Some(title) = request.title.as_deref() {
416 window.set_title(title).map_err(|err| err.to_string())?;
417 }
418 return Ok((window, true));
419 }
420
421 let mut builder = WebviewWindowBuilder::new(
422 handle,
423 &request.label,
424 WebviewUrl::App(request.url.clone().into()),
425 )
426 .visible(false);
427
428 if let Some(title) = request.title.as_deref() {
429 builder = builder.title(title);
430 }
431
432 let builder = attach_native_parent(handle, builder, request.parent.as_deref())?;
433 builder
434 .build()
435 .map(|window| (window, false))
436 .map_err(|err| err.to_string())
437}
438
439fn reveal_window<R: Runtime>(window: &tauri::WebviewWindow<R>) -> Result<(), String> {
440 if let Ok(true) = window.is_minimized() {
441 window.unminimize().map_err(|err| err.to_string())?;
442 }
443
444 window.show().map_err(|err| err.to_string())?;
445
446 if let Err(err) = window.set_focus() {
447 eprintln!(
448 "window-system: failed to focus window label={} error={err}",
449 window.label()
450 );
451 }
452
453 Ok(())
454}
455
456#[cfg(any(debug_assertions, feature = "window-timings"))]
457fn log_operation_timing(
458 operation: &str,
459 subject: Option<&str>,
460 stage: &str,
461 stage_ms: u128,
462 total_ms: u128,
463) {
464 let subject = subject.unwrap_or("-");
465
466 #[cfg(debug_assertions)]
467 eprintln!(
468 "window-system: operation={} subject={} stage={} stage_ms={} total_ms={}",
469 operation, subject, stage, stage_ms, total_ms
470 );
471
472 #[cfg(all(not(debug_assertions), feature = "window-timings"))]
473 tracing::debug!(
474 operation = operation,
475 subject = subject,
476 stage = stage,
477 stage_ms = stage_ms,
478 total_ms = total_ms,
479 "window-system timing"
480 );
481}
482
483#[cfg(any(debug_assertions, feature = "window-timings"))]
484fn log_operation_completion(
485 operation: &str,
486 subject: Option<&str>,
487 total_ms: u128,
488 summary: Option<&str>,
489) {
490 let subject = subject.unwrap_or("-");
491
492 #[cfg(debug_assertions)]
493 match summary {
494 Some(summary) => eprintln!(
495 "window-system: operation={} subject={} stage=complete total_ms={} {}",
496 operation, subject, total_ms, summary
497 ),
498 None => eprintln!(
499 "window-system: operation={} subject={} stage=complete total_ms={}",
500 operation, subject, total_ms
501 ),
502 }
503
504 #[cfg(all(not(debug_assertions), feature = "window-timings"))]
505 match summary {
506 Some(summary) => tracing::debug!(
507 operation = operation,
508 subject = subject,
509 total_ms = total_ms,
510 summary = summary,
511 "window-system operation complete"
512 ),
513 None => tracing::debug!(
514 operation = operation,
515 subject = subject,
516 total_ms = total_ms,
517 "window-system operation complete"
518 ),
519 }
520}
521
522#[command]
523pub async fn close_window<R: Runtime>(
524 handle: AppHandle<R>,
525 registry: State<'_, WindowRegistry>,
526 label: String,
527) -> Result<(), String> {
528 if let Err(err) = close_window_tree_impl(&handle, &*registry, &label, false).await {
529 eprintln!(
530 "{}",
531 crate::lifecycle::format_teardown_failure("command-close", &label, &err)
532 );
533 return Err(err);
534 }
535
536 Ok(())
537}
538
539#[command]
540pub fn list_windows(registry: State<'_, WindowRegistry>) -> Result<Vec<WindowDescriptor>, String> {
541 registry.list()
542}
543
544#[command]
545pub fn emit_to_window<R: Runtime>(
546 handle: AppHandle<R>,
547 label: String,
548 event: String,
549 payload: Value,
550) -> Result<(), String> {
551 crate::events::emit_to_window(&handle, &label, &event, payload)
552}
553
554#[command]
555pub fn send_window_message<R: Runtime>(
556 window: Window<R>,
557 handle: AppHandle<R>,
558 registry: State<'_, WindowRegistry>,
559 request: SendWindowMessageRequest,
560) -> Result<WindowMessageDispatchResult, String> {
561 send_window_message_impl(window, handle, registry, request)
562}
563
564#[command]
565pub fn broadcast_window_message<R: Runtime>(
566 window: Window<R>,
567 handle: AppHandle<R>,
568 registry: State<'_, WindowRegistry>,
569 request: BroadcastWindowMessageRequest,
570) -> Result<WindowMessageDispatchResult, String> {
571 broadcast_window_message_impl(window, handle, registry, request)
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use crate::registry::WindowStateStore;
578 use serde_json::json;
579 use std::collections::HashSet;
580 use std::path::PathBuf;
581 use std::time::{SystemTime, UNIX_EPOCH};
582
583 fn descriptor(label: &str, parent: Option<&str>) -> WindowDescriptor {
584 WindowDescriptor {
585 label: label.into(),
586 url: format!("{label}.html"),
587 parent: parent.map(str::to_string),
588 title: None,
589 geometry: None,
590 }
591 }
592
593 fn unique_temp_path(name: &str) -> PathBuf {
594 let suffix = SystemTime::now()
595 .duration_since(UNIX_EPOCH)
596 .expect("system clock should be monotonic for tests")
597 .as_nanos();
598 std::env::temp_dir().join(format!("tauri-window-system-commands-{name}-{suffix}.json"))
599 }
600
601 #[test]
602 fn restore_plan_is_parent_first() {
603 let plan = plan_restore_windows(
604 vec![descriptor("child", Some("main")), descriptor("main", None)],
605 HashSet::new(),
606 );
607
608 assert_eq!(
609 plan.restore_order
610 .iter()
611 .map(|descriptor| descriptor.label.as_str())
612 .collect::<Vec<_>>(),
613 vec!["main", "child"]
614 );
615 assert!(plan.already_alive.is_empty());
616 assert!(plan.skipped.is_empty());
617 }
618
619 #[test]
620 fn restore_plan_marks_existing_windows_as_alive() {
621 let mut live = HashSet::new();
622 live.insert("main".to_string());
623
624 let plan = plan_restore_windows(
625 vec![descriptor("main", None), descriptor("child", Some("main"))],
626 live,
627 );
628
629 assert_eq!(plan.already_alive, vec!["main".to_string()]);
630 assert_eq!(
631 plan.restore_order
632 .iter()
633 .map(|descriptor| descriptor.label.as_str())
634 .collect::<Vec<_>>(),
635 vec!["child"]
636 );
637 }
638
639 #[test]
640 fn restore_plan_skips_unresolved_children() {
641 let plan = plan_restore_windows(vec![descriptor("child", Some("missing"))], HashSet::new());
642
643 assert!(plan.restore_order.is_empty());
644 assert!(plan.already_alive.is_empty());
645 assert_eq!(plan.skipped.len(), 1);
646 assert_eq!(plan.skipped[0].label, "child");
647 }
648
649 #[test]
650 fn cleanup_skipped_restore_windows_removes_persisted_state() {
651 let path = unique_temp_path("cleanup");
652 let store = WindowStateStore::new(path.clone());
653 let registry = WindowRegistry::new(store.clone());
654 let geometry = WindowGeometry {
655 x: 10.0,
656 y: 20.0,
657 width: 30.0,
658 height: 40.0,
659 };
660
661 store
662 .remember("child", geometry.clone())
663 .expect("child geometry should persist");
664 store
665 .remember_window(WindowDescriptor {
666 label: "child".into(),
667 url: "child.html".into(),
668 parent: Some("missing".into()),
669 title: Some("Child".into()),
670 geometry: Some(geometry),
671 })
672 .expect("child descriptor should persist");
673
674 let plan = RestorePlan {
675 restore_order: Vec::new(),
676 already_alive: Vec::new(),
677 already_alive_descriptors: Vec::new(),
678 skipped: vec![RestoreSkippedWindow {
679 label: "child".into(),
680 parent: Some("missing".into()),
681 reason: RestoreSkipReason::MissingParent,
682 }],
683 live_labels: HashSet::new(),
684 };
685
686 cleanup_stale_restore_windows(®istry, &plan).expect("cleanup should flush");
687
688 assert!(registry
689 .restore_tracked_windows()
690 .iter()
691 .all(|window| window.label != "child"));
692 assert_eq!(registry.restore_geometry("child"), None);
693
694 let restored_store = WindowStateStore::new(path);
695 assert!(restored_store
696 .restore_tracked_windows()
697 .iter()
698 .all(|window| window.label != "child"));
699 assert_eq!(restored_store.restore("child"), None);
700 }
701
702 #[test]
703 fn cleanup_stale_restore_windows_removes_orphaned_alive_child() {
704 let path = unique_temp_path("orphan-alive");
705 let store = WindowStateStore::new(path.clone());
706 let registry = WindowRegistry::new(store.clone());
707 let descriptor = WindowDescriptor {
708 label: "child".into(),
709 url: "child.html".into(),
710 parent: Some("missing".into()),
711 title: Some("Child".into()),
712 geometry: None,
713 };
714
715 store
716 .remember_window(descriptor.clone())
717 .expect("tracked child should persist");
718 store.flush().expect("snapshot should flush");
719
720 let mut live_labels = HashSet::new();
721 live_labels.insert("child".to_string());
722
723 let plan = RestorePlan {
724 restore_order: Vec::new(),
725 already_alive: vec!["child".into()],
726 already_alive_descriptors: vec![descriptor],
727 skipped: Vec::new(),
728 live_labels,
729 };
730
731 cleanup_stale_restore_windows(®istry, &plan).expect("cleanup should flush");
732
733 let restored_store = WindowStateStore::new(path);
734 assert!(restored_store.restore_tracked_windows().is_empty());
735 }
736
737 #[test]
738 fn normalize_open_window_request_trims_and_restores_geometry() {
739 let registry = WindowRegistry::new(WindowStateStore::new(unique_temp_path("normalize")));
740 let geometry = WindowGeometry {
741 x: 10.0,
742 y: 20.0,
743 width: 300.0,
744 height: 200.0,
745 };
746
747 registry
748 .insert(descriptor("main", None))
749 .expect("main should insert");
750 registry
751 .remember_geometry("main", geometry.clone())
752 .expect("geometry should persist");
753
754 let normalized = normalize_open_window_request(
755 ®istry,
756 OpenWindowRequest {
757 label: " main ".into(),
758 url: Some(" child.html ".into()),
759 parent: Some(" root ".into()),
760 title: Some(" Child Window ".into()),
761 geometry: None,
762 },
763 )
764 .expect("request should normalize");
765
766 assert_eq!(normalized.label, "main");
767 assert_eq!(normalized.url, "child.html");
768 assert_eq!(normalized.parent, Some("root".into()));
769 assert_eq!(normalized.title, Some("Child Window".into()));
770 assert_eq!(normalized.geometry, Some(geometry));
771 }
772
773 #[test]
774 fn normalize_open_window_request_rejects_blank_label() {
775 let registry = WindowRegistry::new(WindowStateStore::new(unique_temp_path("invalid")));
776
777 let error = normalize_open_window_request(
778 ®istry,
779 OpenWindowRequest {
780 label: " ".into(),
781 url: None,
782 parent: None,
783 title: None,
784 geometry: None,
785 },
786 )
787 .expect_err("blank label should be rejected");
788
789 assert!(error.contains("invalid-label"));
790 assert!(error.contains("label is required"));
791 }
792
793 #[test]
794 fn restore_windows_result_serializes_with_stable_field_names() {
795 let result = RestoreWindowsResult {
796 restored: vec![descriptor("main", None)],
797 already_alive: vec!["child".into()],
798 skipped: vec![RestoreSkippedWindow {
799 label: "orphan".into(),
800 parent: Some("missing".into()),
801 reason: RestoreSkipReason::MissingParent,
802 }],
803 };
804
805 let value = serde_json::to_value(result).expect("result should serialize");
806
807 assert_eq!(value["restored"][0]["label"], "main");
808 assert_eq!(value["alreadyAlive"], json!(["child"]));
809 assert_eq!(value["skipped"][0]["reason"], "missing-parent");
810 assert_eq!(value["skipped"][0]["parent"], "missing");
811 }
812}