Skip to main content

rightkit_tauri_shell/
windows.rs

1//! Window creation and lifecycle helpers: hardened builders, reveal / toggle, secondary windows.
2//!
3//! Donors: CodeRight `os_shell/windows` (bounded labels and dimensions, app-relative URLs only,
4//! lifecycle event), HeardRight `hub_window` (reveal = show + unminimize + focus; macOS needs
5//! `app.show()` first), ViewRight (hidden-until-first-paint).
6
7use std::sync::Arc;
8
9use serde::{Deserialize, Serialize};
10use tauri::webview::NewWindowResponse;
11use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder, Wry};
12
13use crate::lifecycle::{reveal_mode, RevealMode, RevealSource};
14use crate::nav::WindowOpenDecision;
15use crate::opener::open_checked;
16use crate::{ShellState, EVENT_WINDOW};
17
18/// App event announcing initial window placement (`phase` `"begin"` / `"done"`), so a
19/// control server can hold viewport requests until placement and state restore have
20/// landed instead of racing them. Same name `rightkit-qa-driver` listens on. Announcing
21/// never shows, orders or focuses a window.
22pub const EVENT_PLACEMENT: &str = "rk-shell:placement";
23
24fn announce_placement(app: &AppHandle<Wry>, label: &str, phase: &str) {
25    let _ = app.emit(
26        EVENT_PLACEMENT,
27        serde_json::json!({"label": label, "phase": phase}),
28    );
29}
30
31/// Run `place` (window-state restore and any other initial placement) between the
32/// `begin` and `done` announcements. `done` is queued on the main thread behind the
33/// size and position calls `place` made, so it is not announced before they apply.
34pub fn with_placement_announced(app: &AppHandle<Wry>, label: &str, place: impl FnOnce()) {
35    announce_placement(app, label, "begin");
36    place();
37    let (a, l) = (app.clone(), label.to_owned());
38    if app
39        .run_on_main_thread(move || announce_placement(&a, &l, "done"))
40        .is_err()
41    {
42        announce_placement(app, label, "done");
43    }
44}
45
46const MIN_WIDTH: f64 = 320.0;
47const MIN_HEIGHT: f64 = 240.0;
48
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct WindowSpec {
52    pub label: String,
53    pub title: Option<String>,
54    /// App-relative path (e.g. `mini.html`); never an absolute or remote URL.
55    pub path: Option<String>,
56    pub width: Option<f64>,
57    pub height: Option<f64>,
58    pub focused: Option<bool>,
59    /// Always on top (mini windows, pills).
60    pub always_on_top: Option<bool>,
61    /// Minimum inner width in logical pixels. Default 320 (unchanged); a non-finite or non-positive
62    /// value is ignored. The initial width is raised to at least this.
63    pub min_width: Option<f64>,
64    /// Minimum inner height in logical pixels. Default 240 (unchanged).
65    pub min_height: Option<f64>,
66    /// Override native decorations after the shell's frameless policy (`Some(false)` for a
67    /// borderless pill or onboarding window on a framed shell, `Some(true)` for the reverse).
68    pub decorations: Option<bool>,
69    /// Centre on the primary/current monitor. Wins over `x` / `y`.
70    pub center: Option<bool>,
71    /// Open fullscreen.
72    pub fullscreen: Option<bool>,
73    /// Keep the window out of the taskbar / Dock-switcher where the platform supports it.
74    pub skip_taskbar: Option<bool>,
75    /// Initial outer position in logical pixels (needs both `x` and `y`). It is corrected to a
76    /// visible monitor when it would leave the window off-screen.
77    pub x: Option<f64>,
78    pub y: Option<f64>,
79}
80
81#[derive(Debug, Clone, Serialize)]
82pub struct WindowReadback {
83    pub visible: bool,
84    pub focused: bool,
85    pub main: bool,
86    pub level: Option<i64>,
87}
88
89pub fn safe_label(raw: &str) -> Option<String> {
90    let c = raw.trim();
91    (!c.is_empty()
92        && c.len() <= 80
93        && c.chars()
94            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')))
95    .then(|| c.to_owned())
96}
97
98fn app_path(path: Option<&str>) -> String {
99    let raw = path.unwrap_or("index.html").trim().trim_start_matches('/');
100    if raw.is_empty()
101        || raw.contains("..")
102        || raw.contains('\\')
103        || raw.contains('\0')
104        || raw.contains("://")
105    {
106        "index.html".to_owned()
107    } else {
108        raw.to_owned()
109    }
110}
111
112/// The effective minimum inner size of `spec`: its own values, else the shell default.
113pub fn spec_min_size(spec: &WindowSpec) -> (f64, f64) {
114    let pick = |v: Option<f64>, default: f64| {
115        v.filter(|v| v.is_finite() && *v > 0.0)
116            .map_or(default, |v| v.min(16_384.0))
117    };
118    (
119        pick(spec.min_width, MIN_WIDTH),
120        pick(spec.min_height, MIN_HEIGHT),
121    )
122}
123
124/// Move a window rectangle (logical pixels) back on-screen. A rectangle whose title strip is
125/// reachable on some monitor (at least `CLAMP_MARGIN_X` wide, `CLAMP_MARGIN_Y` tall inside it)
126/// is returned unchanged; otherwise it is clamped onto the monitor nearest its centre. No monitors:
127/// unchanged.
128pub fn correct_offscreen(
129    x: f64,
130    y: f64,
131    width: f64,
132    height: f64,
133    monitors: &[crate::window_state::MonitorSnapshot],
134) -> (f64, f64) {
135    use crate::window_state::{CLAMP_MARGIN_X, CLAMP_MARGIN_Y};
136    if monitors.is_empty() || !x.is_finite() || !y.is_finite() {
137        return match (monitors.first(), x.is_finite() && y.is_finite()) {
138            (Some(m), false) => (m.x, m.y),
139            _ => (x, y),
140        };
141    }
142    let reachable = |m: &crate::window_state::MonitorSnapshot| {
143        x + width > m.x + CLAMP_MARGIN_X
144            && x < m.x + m.width - CLAMP_MARGIN_X
145            && y >= m.y
146            && y < m.y + m.height - CLAMP_MARGIN_Y
147    };
148    if monitors.iter().any(reachable) {
149        return (x, y);
150    }
151    let (cx, cy) = (x + width / 2.0, y + height / 2.0);
152    let target = monitors
153        .iter()
154        .min_by(|a, b| {
155            let d = |m: &crate::window_state::MonitorSnapshot| {
156                (m.x + m.width / 2.0 - cx).powi(2) + (m.y + m.height / 2.0 - cy).powi(2)
157            };
158            d(a).total_cmp(&d(b))
159        })
160        .expect("non-empty monitors");
161    let min_x = target.x - (width - CLAMP_MARGIN_X).max(0.0);
162    let max_x = target.x + target.width - CLAMP_MARGIN_X;
163    let max_y = target.y + target.height - CLAMP_MARGIN_Y;
164    (
165        if min_x <= max_x {
166            x.clamp(min_x, max_x)
167        } else {
168            target.x
169        },
170        if target.y <= max_y {
171            y.clamp(target.y, max_y)
172        } else {
173            target.y
174        },
175    )
176}
177
178fn dim(v: Option<f64>, default: f64, min: f64) -> f64 {
179    v.filter(|v| v.is_finite() && *v > 0.0)
180        .unwrap_or(default)
181        .max(min)
182}
183
184/// A `WebviewWindowBuilder` carrying the shell policy: popup handling through [`crate::nav`],
185/// frameless chrome when the shell was configured for it, hidden until restored/shown.
186/// Navigation, init scripts and native hardening come from the shell plugin for every webview.
187pub fn builder<'a>(
188    app: &'a AppHandle<Wry>,
189    label: &str,
190    url: WebviewUrl,
191) -> WebviewWindowBuilder<'a, Wry, AppHandle<Wry>> {
192    let state = app.state::<ShellState>();
193    let nav = Arc::clone(&state.nav);
194    let opener = Arc::clone(&state.opener);
195    let b = WebviewWindowBuilder::new(app, label, url)
196        .visible(false)
197        .on_new_window(move |url, features| {
198            match nav.decide_window_open(&url, features.size().is_some(), None) {
199                WindowOpenDecision::External { url } => {
200                    let _ = open_checked(opener.as_ref(), &url);
201                    NewWindowResponse::Deny
202                }
203                WindowOpenDecision::Popup => NewWindowResponse::Allow,
204                WindowOpenDecision::Deny => NewWindowResponse::Deny,
205            }
206        });
207    // A never-show host's windows are never created focused.
208    let b = if crate::lifecycle::never_show(app) {
209        b.focused(false)
210    } else {
211        b
212    };
213    crate::frameless::style(b, state.frameless)
214}
215
216/// Create (or reveal) a secondary window described by `spec`.
217pub fn open(app: &AppHandle<Wry>, spec: &WindowSpec) -> Result<String, String> {
218    let label = safe_label(&spec.label).ok_or("invalid window label")?;
219    if let Some(w) = app.get_webview_window(&label) {
220        reveal_with(app, Some(&label), RevealSource::OpenWindow);
221        return Ok(w.label().to_owned());
222    }
223    let title = spec
224        .title
225        .as_deref()
226        .map(str::trim)
227        .filter(|t| !t.is_empty())
228        .unwrap_or(&app.state::<ShellState>().app_name)
229        .to_owned();
230    let focused = reveal_mode(app, RevealSource::OpenWindow) == RevealMode::Foreground
231        && spec.focused.unwrap_or(true);
232    let (min_w, min_h) = spec_min_size(spec);
233    let (width, height) = (
234        dim(spec.width, 900.0, min_w),
235        dim(spec.height, 640.0, min_h),
236    );
237    let mut b = builder(
238        app,
239        &label,
240        WebviewUrl::App(app_path(spec.path.as_deref()).into()),
241    )
242    .title(title)
243    .inner_size(width, height)
244    .min_inner_size(min_w, min_h)
245    .always_on_top(spec.always_on_top.unwrap_or(false))
246    .focused(focused);
247    if let Some(d) = spec.decorations {
248        b = b.decorations(d);
249    }
250    if let Some(f) = spec.fullscreen {
251        b = b.fullscreen(f);
252    }
253    if let Some(s) = spec.skip_taskbar {
254        b = b.skip_taskbar(s);
255    }
256    if spec.center == Some(true) {
257        b = b.center();
258    } else if let (Some(x), Some(y)) = (spec.x, spec.y) {
259        let monitors: Vec<_> = app
260            .available_monitors()
261            .unwrap_or_default()
262            .iter()
263            .map(crate::window_state::MonitorSnapshot::from_monitor)
264            .collect();
265        let (x, y) = correct_offscreen(x, y, width, height, &monitors);
266        b = b.position(x, y);
267    }
268    let window = b
269        .build()
270        .map_err(|e| format!("create window {label}: {e}"))?;
271    let _ = app.emit(
272        EVENT_WINDOW,
273        serde_json::json!({"label": label, "kind": "created"}),
274    );
275    show_window(
276        app,
277        &window,
278        RevealSource::OpenWindow,
279        RevealStyle::ShowOnly,
280    )?;
281    Ok(label)
282}
283
284/// The window calls one reveal makes, so every path is testable (and recordable) without a window.
285pub trait RevealTarget {
286    /// Before a foreground reveal of the main window: back to a Regular app, run the host's
287    /// visibility callback.
288    fn prepare(&mut self);
289    /// macOS `app.show()`.
290    fn app_show(&mut self);
291    fn unminimize(&mut self) -> Result<(), String>;
292    fn show(&mut self) -> Result<(), String>;
293    fn focus(&mut self) -> Result<(), String>;
294    /// QA background: native order-back without activation.
295    fn order_back(&mut self) -> Result<(), String>;
296}
297
298/// Which calls a foreground reveal makes.
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub enum RevealStyle {
301    /// prepare, app show, unminimise, show, focus (errors ignored): `reveal`, tray, reopen.
302    Full,
303    /// prepare, show, focus (errors returned): the `rk_shell_window` `show` command.
304    ShowFocus,
305    /// show only (errors returned): show-on-ready and a newly opened window.
306    ShowOnly,
307}
308
309/// Do exactly what `mode` allows. `Ok(true)` when the window was brought up, `Ok(false)` when
310/// nothing was (forbidden, or a `Full` order-back that failed). `Forbidden` makes no call at all.
311pub fn apply_reveal<T: RevealTarget>(
312    mode: RevealMode,
313    style: RevealStyle,
314    target: &mut T,
315) -> Result<bool, String> {
316    match mode {
317        RevealMode::Forbidden => Ok(false),
318        RevealMode::Background => match target.order_back() {
319            Ok(()) => Ok(true),
320            Err(_) if style == RevealStyle::Full => Ok(false),
321            Err(e) => Err(e),
322        },
323        RevealMode::Foreground => {
324            match style {
325                RevealStyle::Full => {
326                    target.prepare();
327                    target.app_show();
328                    let _ = target.unminimize();
329                    let _ = target.show();
330                    let _ = target.focus();
331                }
332                RevealStyle::ShowFocus => {
333                    target.prepare();
334                    target.show()?;
335                    target.focus()?;
336                }
337                RevealStyle::ShowOnly => target.show()?,
338            }
339            Ok(true)
340        }
341    }
342}
343
344struct WindowReveal<'a> {
345    app: &'a AppHandle<Wry>,
346    w: &'a WebviewWindow<Wry>,
347}
348
349impl RevealTarget for WindowReveal<'_> {
350    fn prepare(&mut self) {
351        // Back to a Regular app first (a hide-to-background host is an Accessory app while
352        // hidden), so the activation below can bring a Dock icon and menu bar with it.
353        if self.w.label() == self.app.state::<ShellState>().main_label {
354            crate::lifecycle::note_visibility(self.app, crate::lifecycle::Visibility::Revealed);
355        }
356    }
357    fn app_show(&mut self) {
358        #[cfg(target_os = "macos")]
359        let _ = self.app.show();
360    }
361    fn unminimize(&mut self) -> Result<(), String> {
362        self.w.unminimize().map_err(|e| e.to_string())
363    }
364    fn show(&mut self) -> Result<(), String> {
365        self.w.show().map_err(|e| e.to_string())
366    }
367    fn focus(&mut self) -> Result<(), String> {
368        self.w.set_focus().map_err(|e| e.to_string())
369    }
370    fn order_back(&mut self) -> Result<(), String> {
371        // Native AppKit ordering must run on the Tauri main thread. Window commands
372        // arrive on rightkit-daemon connection threads; dispatch synchronously there
373        // so errors are returned to the caller after WindowServer applies the order.
374        #[cfg(target_os = "macos")]
375        {
376            dispatch_native(self.w, order_back_native)
377        }
378        #[cfg(not(target_os = "macos"))]
379        {
380            Err("background native ordering is unimplemented on this platform".into())
381        }
382    }
383}
384
385/// Bring `w` up as the guard allows for `source`. The only function that touches a window for a
386/// reveal; every caller reaches it through [`crate::lifecycle::reveal_mode`].
387pub(crate) fn show_window(
388    app: &AppHandle<Wry>,
389    w: &WebviewWindow<Wry>,
390    source: RevealSource,
391    style: RevealStyle,
392) -> Result<bool, String> {
393    let mode = reveal_mode(app, source);
394    apply_reveal(mode, style, &mut WindowReveal { app, w })
395}
396
397fn reveal_window(app: &AppHandle<Wry>, w: &WebviewWindow<Wry>, source: RevealSource) {
398    let shown = show_window(app, w, source, RevealStyle::Full).unwrap_or(false);
399    if shown && w.label() == app.state::<ShellState>().main_label {
400        crate::idle::note_visible(app);
401    }
402}
403
404#[cfg(target_os = "macos")]
405pub(crate) fn dispatch_native<T, F>(w: &WebviewWindow<Wry>, f: F) -> Result<T, String>
406where
407    T: Send + 'static,
408    F: FnOnce(&WebviewWindow<Wry>) -> Result<T, String> + Send + 'static,
409{
410    let Some(state) = w.app_handle().try_state::<ShellState>() else {
411        return Err("shell state unavailable for native dispatch".into());
412    };
413    if state.exiting.load(std::sync::atomic::Ordering::Acquire) {
414        return Err("native dispatch rejected during shutdown".into());
415    }
416    if std::thread::current().id() == state.main_thread_id {
417        return f(w);
418    }
419    let (tx, rx) = std::sync::mpsc::sync_channel(1);
420    let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
421    let cancelled_task = std::sync::Arc::clone(&cancelled);
422    let app = w.app_handle().clone();
423    let window = w.clone();
424    w.run_on_main_thread(move || {
425        if cancelled_task.load(std::sync::atomic::Ordering::Acquire) {
426            let _ = tx.send(Err("native dispatch cancelled".into()));
427            return;
428        }
429        let Some(state) = app.try_state::<ShellState>() else {
430            let _ = tx.send(Err("shell state unavailable for native dispatch".into()));
431            return;
432        };
433        if state.exiting.load(std::sync::atomic::Ordering::Acquire) {
434            let _ = tx.send(Err("native dispatch rejected during shutdown".into()));
435            return;
436        }
437        let _ = tx.send(f(&window));
438    })
439    .map_err(|e| e.to_string())?;
440    match rx.recv_timeout(std::time::Duration::from_secs(5)) {
441        Ok(result) => result,
442        Err(e) => {
443            cancelled.store(true, std::sync::atomic::Ordering::Release);
444            Err(format!("main-thread native dispatch: {e}"))
445        }
446    }
447}
448
449// Dispatched to the main thread only on macOS.
450#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
451fn order_back_native(w: &WebviewWindow<Wry>) -> Result<(), String> {
452    #[cfg(target_os = "macos")]
453    {
454        use objc2_app_kit::NSWindow;
455        let ptr = w.ns_window().map_err(|e| e.to_string())?;
456        if ptr.is_null() {
457            return Err("native window handle unavailable".into());
458        }
459        // SAFETY: Tauri returns the live NSWindow for this window; used synchronously.
460        let native = unsafe { &*(ptr as *const NSWindow) };
461        // Keep QA windows below normal user windows even if AppKit reorders them later.
462        native.setLevel(-1);
463        native.orderBack(None);
464        Ok(())
465    }
466    #[cfg(not(target_os = "macos"))]
467    {
468        let _ = w;
469        Err("background native ordering is unimplemented on this platform".into())
470    }
471}
472
473/// Read native window visibility/order after a show or order operation. macOS values
474/// come from NSWindow, so Tauri's logical `is_visible` cannot mask a failed native order.
475pub fn readback(w: &WebviewWindow<Wry>) -> Result<WindowReadback, String> {
476    #[cfg(target_os = "macos")]
477    {
478        dispatch_native(w, |w| {
479            use objc2_app_kit::NSWindow;
480            let ptr = w.ns_window().map_err(|e| e.to_string())?;
481            if ptr.is_null() {
482                return Err("native window handle unavailable".into());
483            }
484            // SAFETY: Tauri returns the live NSWindow for this window; used synchronously.
485            let native = unsafe { &*(ptr as *const NSWindow) };
486            Ok(WindowReadback {
487                visible: native.isVisible(),
488                focused: native.isKeyWindow(),
489                main: native.isMainWindow(),
490                level: Some(native.level() as i64),
491            })
492        })
493    }
494    #[cfg(not(target_os = "macos"))]
495    Ok(WindowReadback {
496        visible: w.is_visible().unwrap_or(false),
497        focused: w.is_focused().unwrap_or(false),
498        main: false,
499        level: None,
500    })
501}
502
503/// Show + unminimize + focus, as the host's visibility policy allows (never-show makes this a
504/// no-op). `None` targets the main window.
505pub fn reveal(app: &AppHandle<Wry>, label: Option<&str>) {
506    reveal_with(app, label, RevealSource::Api);
507}
508
509/// [`reveal`] naming why, for the guard.
510pub fn reveal_with(app: &AppHandle<Wry>, label: Option<&str>, source: RevealSource) {
511    let main = app.state::<ShellState>().main_label.clone();
512    let label = label.unwrap_or(&main);
513    if let Some(w) = app.get_webview_window(label) {
514        reveal_window(app, &w, source);
515    }
516}
517
518/// Hide when visible and focused-or-visible, otherwise reveal (tray left-click behaviour).
519pub fn toggle(app: &AppHandle<Wry>, label: &str) {
520    if let Some(w) = app.get_webview_window(label) {
521        if w.is_visible().unwrap_or(false) && w.is_focused().unwrap_or(false) {
522            let _ = w.hide();
523            if w.label() == app.state::<ShellState>().main_label {
524                crate::idle::note_hidden(app, false);
525                crate::lifecycle::note_visibility(app, crate::lifecycle::Visibility::Hidden);
526            }
527        } else {
528            reveal_window(app, &w, RevealSource::Tray);
529        }
530    }
531}