1use 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
18pub 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
31pub 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 pub path: Option<String>,
56 pub width: Option<f64>,
57 pub height: Option<f64>,
58 pub focused: Option<bool>,
59 pub always_on_top: Option<bool>,
61 pub min_width: Option<f64>,
64 pub min_height: Option<f64>,
66 pub decorations: Option<bool>,
69 pub center: Option<bool>,
71 pub fullscreen: Option<bool>,
73 pub skip_taskbar: Option<bool>,
75 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
112pub 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
124pub 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
184pub 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 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 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
222pub 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
290pub trait RevealTarget {
292 fn prepare(&mut self);
295 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 fn order_back(&mut self) -> Result<(), String>;
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum RevealStyle {
307 Full,
309 ShowFocus,
311 ShowOnly,
313}
314
315pub 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 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 #[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
391pub(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#[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 let native = unsafe { &*(ptr as *const NSWindow) };
467 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
479pub 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 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
509pub fn reveal(app: &AppHandle<Wry>, label: Option<&str>) {
512 reveal_with(app, label, RevealSource::Api);
513}
514
515pub 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
524pub 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}