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 (opener_app, opener_label) = (app.clone(), label.to_owned());
196    let b = WebviewWindowBuilder::new(app, label, url)
197        .visible(false)
198        .on_new_window(move |url, features| {
199            // The opening page: this window's current URL. Unknown (the window is not registered yet)
200            // defers to the origin allow-list.
201            let from = opener_app
202                .get_webview_window(&opener_label)
203                .and_then(|w| w.url().ok());
204            match nav.decide_window_open(&url, features.size().is_some(), from.as_ref()) {
205                WindowOpenDecision::External { url } => {
206                    let _ = open_checked(opener.as_ref(), &url);
207                    NewWindowResponse::Deny
208                }
209                WindowOpenDecision::Popup => NewWindowResponse::Allow,
210                WindowOpenDecision::Deny => NewWindowResponse::Deny,
211            }
212        });
213    // A never-show host's windows are never created focused.
214    let b = if crate::lifecycle::never_show(app) {
215        b.focused(false)
216    } else {
217        b
218    };
219    crate::frameless::style(b, state.frameless)
220}
221
222/// Create (or reveal) a secondary window described by `spec`.
223pub fn open(app: &AppHandle<Wry>, spec: &WindowSpec) -> Result<String, String> {
224    let label = safe_label(&spec.label).ok_or("invalid window label")?;
225    if let Some(w) = app.get_webview_window(&label) {
226        reveal_with(app, Some(&label), RevealSource::OpenWindow);
227        return Ok(w.label().to_owned());
228    }
229    let title = spec
230        .title
231        .as_deref()
232        .map(str::trim)
233        .filter(|t| !t.is_empty())
234        .unwrap_or(&app.state::<ShellState>().app_name)
235        .to_owned();
236    let focused = reveal_mode(app, RevealSource::OpenWindow) == RevealMode::Foreground
237        && spec.focused.unwrap_or(true);
238    let (min_w, min_h) = spec_min_size(spec);
239    let (width, height) = (
240        dim(spec.width, 900.0, min_w),
241        dim(spec.height, 640.0, min_h),
242    );
243    let mut b = builder(
244        app,
245        &label,
246        WebviewUrl::App(app_path(spec.path.as_deref()).into()),
247    )
248    .title(title)
249    .inner_size(width, height)
250    .min_inner_size(min_w, min_h)
251    .always_on_top(spec.always_on_top.unwrap_or(false))
252    .focused(focused);
253    if let Some(d) = spec.decorations {
254        b = b.decorations(d);
255    }
256    if let Some(f) = spec.fullscreen {
257        b = b.fullscreen(f);
258    }
259    if let Some(s) = spec.skip_taskbar {
260        b = b.skip_taskbar(s);
261    }
262    if spec.center == Some(true) {
263        b = b.center();
264    } else if let (Some(x), Some(y)) = (spec.x, spec.y) {
265        let monitors: Vec<_> = app
266            .available_monitors()
267            .unwrap_or_default()
268            .iter()
269            .map(crate::window_state::MonitorSnapshot::from_monitor)
270            .collect();
271        let (x, y) = correct_offscreen(x, y, width, height, &monitors);
272        b = b.position(x, y);
273    }
274    let window = b
275        .build()
276        .map_err(|e| format!("create window {label}: {e}"))?;
277    let _ = app.emit(
278        EVENT_WINDOW,
279        serde_json::json!({"label": label, "kind": "created"}),
280    );
281    show_window(
282        app,
283        &window,
284        RevealSource::OpenWindow,
285        RevealStyle::ShowOnly,
286    )?;
287    Ok(label)
288}
289
290/// The window calls one reveal makes, so every path is testable (and recordable) without a window.
291pub trait RevealTarget {
292    /// Before a foreground reveal of the main window: back to a Regular app, run the host's
293    /// visibility callback.
294    fn prepare(&mut self);
295    /// macOS `app.show()`.
296    fn app_show(&mut self);
297    fn unminimize(&mut self) -> Result<(), String>;
298    fn show(&mut self) -> Result<(), String>;
299    fn focus(&mut self) -> Result<(), String>;
300    /// QA background: native order-back without activation.
301    fn order_back(&mut self) -> Result<(), String>;
302}
303
304/// Which calls a foreground reveal makes.
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum RevealStyle {
307    /// prepare, app show, unminimise, show, focus (errors ignored): `reveal`, tray, reopen.
308    Full,
309    /// prepare, show, focus (errors returned): the `rk_shell_window` `show` command.
310    ShowFocus,
311    /// show only (errors returned): show-on-ready and a newly opened window.
312    ShowOnly,
313}
314
315/// Do exactly what `mode` allows. `Ok(true)` when the window was brought up, `Ok(false)` when
316/// nothing was (forbidden, or a `Full` order-back that failed). `Forbidden` makes no call at all.
317pub fn apply_reveal<T: RevealTarget>(
318    mode: RevealMode,
319    style: RevealStyle,
320    target: &mut T,
321) -> Result<bool, String> {
322    match mode {
323        RevealMode::Forbidden => Ok(false),
324        RevealMode::Background => match target.order_back() {
325            Ok(()) => Ok(true),
326            Err(_) if style == RevealStyle::Full => Ok(false),
327            Err(e) => Err(e),
328        },
329        RevealMode::Foreground => {
330            match style {
331                RevealStyle::Full => {
332                    target.prepare();
333                    target.app_show();
334                    let _ = target.unminimize();
335                    let _ = target.show();
336                    let _ = target.focus();
337                }
338                RevealStyle::ShowFocus => {
339                    target.prepare();
340                    target.show()?;
341                    target.focus()?;
342                }
343                RevealStyle::ShowOnly => target.show()?,
344            }
345            Ok(true)
346        }
347    }
348}
349
350struct WindowReveal<'a> {
351    app: &'a AppHandle<Wry>,
352    w: &'a WebviewWindow<Wry>,
353}
354
355impl RevealTarget for WindowReveal<'_> {
356    fn prepare(&mut self) {
357        // Back to a Regular app first (a hide-to-background host is an Accessory app while
358        // hidden), so the activation below can bring a Dock icon and menu bar with it.
359        if self.w.label() == self.app.state::<ShellState>().main_label {
360            crate::lifecycle::note_visibility(self.app, crate::lifecycle::Visibility::Revealed);
361        }
362    }
363    fn app_show(&mut self) {
364        #[cfg(target_os = "macos")]
365        let _ = self.app.show();
366    }
367    fn unminimize(&mut self) -> Result<(), String> {
368        self.w.unminimize().map_err(|e| e.to_string())
369    }
370    fn show(&mut self) -> Result<(), String> {
371        self.w.show().map_err(|e| e.to_string())
372    }
373    fn focus(&mut self) -> Result<(), String> {
374        self.w.set_focus().map_err(|e| e.to_string())
375    }
376    fn order_back(&mut self) -> Result<(), String> {
377        // Native AppKit ordering must run on the Tauri main thread. Window commands
378        // arrive on rightkit-daemon connection threads; dispatch synchronously there
379        // so errors are returned to the caller after WindowServer applies the order.
380        #[cfg(target_os = "macos")]
381        {
382            dispatch_native(self.w, order_back_native)
383        }
384        #[cfg(not(target_os = "macos"))]
385        {
386            Err("background native ordering is unimplemented on this platform".into())
387        }
388    }
389}
390
391/// Bring `w` up as the guard allows for `source`. The only function that touches a window for a
392/// reveal; every caller reaches it through [`crate::lifecycle::reveal_mode`].
393pub(crate) fn show_window(
394    app: &AppHandle<Wry>,
395    w: &WebviewWindow<Wry>,
396    source: RevealSource,
397    style: RevealStyle,
398) -> Result<bool, String> {
399    let mode = reveal_mode(app, source);
400    apply_reveal(mode, style, &mut WindowReveal { app, w })
401}
402
403fn reveal_window(app: &AppHandle<Wry>, w: &WebviewWindow<Wry>, source: RevealSource) {
404    let shown = show_window(app, w, source, RevealStyle::Full).unwrap_or(false);
405    if shown && w.label() == app.state::<ShellState>().main_label {
406        crate::idle::note_visible(app);
407    }
408}
409
410#[cfg(target_os = "macos")]
411pub(crate) fn dispatch_native<T, F>(w: &WebviewWindow<Wry>, f: F) -> Result<T, String>
412where
413    T: Send + 'static,
414    F: FnOnce(&WebviewWindow<Wry>) -> Result<T, String> + Send + 'static,
415{
416    let Some(state) = w.app_handle().try_state::<ShellState>() else {
417        return Err("shell state unavailable for native dispatch".into());
418    };
419    if state.exiting.load(std::sync::atomic::Ordering::Acquire) {
420        return Err("native dispatch rejected during shutdown".into());
421    }
422    if std::thread::current().id() == state.main_thread_id {
423        return f(w);
424    }
425    let (tx, rx) = std::sync::mpsc::sync_channel(1);
426    let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
427    let cancelled_task = std::sync::Arc::clone(&cancelled);
428    let app = w.app_handle().clone();
429    let window = w.clone();
430    w.run_on_main_thread(move || {
431        if cancelled_task.load(std::sync::atomic::Ordering::Acquire) {
432            let _ = tx.send(Err("native dispatch cancelled".into()));
433            return;
434        }
435        let Some(state) = app.try_state::<ShellState>() else {
436            let _ = tx.send(Err("shell state unavailable for native dispatch".into()));
437            return;
438        };
439        if state.exiting.load(std::sync::atomic::Ordering::Acquire) {
440            let _ = tx.send(Err("native dispatch rejected during shutdown".into()));
441            return;
442        }
443        let _ = tx.send(f(&window));
444    })
445    .map_err(|e| e.to_string())?;
446    match rx.recv_timeout(std::time::Duration::from_secs(5)) {
447        Ok(result) => result,
448        Err(e) => {
449            cancelled.store(true, std::sync::atomic::Ordering::Release);
450            Err(format!("main-thread native dispatch: {e}"))
451        }
452    }
453}
454
455// Dispatched to the main thread only on macOS.
456#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
457fn order_back_native(w: &WebviewWindow<Wry>) -> Result<(), String> {
458    #[cfg(target_os = "macos")]
459    {
460        use objc2_app_kit::NSWindow;
461        let ptr = w.ns_window().map_err(|e| e.to_string())?;
462        if ptr.is_null() {
463            return Err("native window handle unavailable".into());
464        }
465        // SAFETY: Tauri returns the live NSWindow for this window; used synchronously.
466        let native = unsafe { &*(ptr as *const NSWindow) };
467        // Keep QA windows below normal user windows even if AppKit reorders them later.
468        native.setLevel(-1);
469        native.orderBack(None);
470        Ok(())
471    }
472    #[cfg(not(target_os = "macos"))]
473    {
474        let _ = w;
475        Err("background native ordering is unimplemented on this platform".into())
476    }
477}
478
479/// Read native window visibility/order after a show or order operation. macOS values
480/// come from NSWindow, so Tauri's logical `is_visible` cannot mask a failed native order.
481pub fn readback(w: &WebviewWindow<Wry>) -> Result<WindowReadback, String> {
482    #[cfg(target_os = "macos")]
483    {
484        dispatch_native(w, |w| {
485            use objc2_app_kit::NSWindow;
486            let ptr = w.ns_window().map_err(|e| e.to_string())?;
487            if ptr.is_null() {
488                return Err("native window handle unavailable".into());
489            }
490            // SAFETY: Tauri returns the live NSWindow for this window; used synchronously.
491            let native = unsafe { &*(ptr as *const NSWindow) };
492            Ok(WindowReadback {
493                visible: native.isVisible(),
494                focused: native.isKeyWindow(),
495                main: native.isMainWindow(),
496                level: Some(native.level() as i64),
497            })
498        })
499    }
500    #[cfg(not(target_os = "macos"))]
501    Ok(WindowReadback {
502        visible: w.is_visible().unwrap_or(false),
503        focused: w.is_focused().unwrap_or(false),
504        main: false,
505        level: None,
506    })
507}
508
509/// Show + unminimize + focus, as the host's visibility policy allows (never-show makes this a
510/// no-op). `None` targets the main window.
511pub fn reveal(app: &AppHandle<Wry>, label: Option<&str>) {
512    reveal_with(app, label, RevealSource::Api);
513}
514
515/// [`reveal`] naming why, for the guard.
516pub fn reveal_with(app: &AppHandle<Wry>, label: Option<&str>, source: RevealSource) {
517    let main = app.state::<ShellState>().main_label.clone();
518    let label = label.unwrap_or(&main);
519    if let Some(w) = app.get_webview_window(label) {
520        reveal_window(app, &w, source);
521    }
522}
523
524/// Hide when visible and focused-or-visible, otherwise reveal (tray left-click behaviour).
525pub fn toggle(app: &AppHandle<Wry>, label: &str) {
526    if let Some(w) = app.get_webview_window(label) {
527        if w.is_visible().unwrap_or(false) && w.is_focused().unwrap_or(false) {
528            let _ = w.hide();
529            if w.label() == app.state::<ShellState>().main_label {
530                crate::idle::note_hidden(app, false);
531                crate::lifecycle::note_visibility(app, crate::lifecycle::Visibility::Hidden);
532            }
533        } else {
534            reveal_window(app, &w, RevealSource::Tray);
535        }
536    }
537}