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 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 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
216pub 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
284pub trait RevealTarget {
286 fn prepare(&mut self);
289 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 fn order_back(&mut self) -> Result<(), String>;
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub enum RevealStyle {
301 Full,
303 ShowFocus,
305 ShowOnly,
307}
308
309pub 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 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 #[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
385pub(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#[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 let native = unsafe { &*(ptr as *const NSWindow) };
461 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
473pub 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 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
503pub fn reveal(app: &AppHandle<Wry>, label: Option<&str>) {
506 reveal_with(app, label, RevealSource::Api);
507}
508
509pub 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
518pub 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}