1use std::ffi::{OsStr, OsString};
20use std::sync::atomic::{AtomicBool, Ordering};
21
22use tauri::{AppHandle, Emitter, Manager, RunEvent, Runtime, WindowEvent, Wry};
23
24use crate::quit::QuitStep;
25use crate::{ShellState, EVENT_DEEP_LINK, EVENT_MENU, EVENT_QUIT_REQUESTED, EVENT_SECOND_INSTANCE};
26
27pub fn launched_in_background() -> bool {
29 std::env::args().any(|a| a == crate::autostart::BACKGROUND_ARG)
30}
31
32pub fn qa_hidden() -> bool {
35 std::env::var_os("RIGHTKIT_QA_HIDDEN").as_deref() == Some(std::ffi::OsStr::new("1"))
36}
37
38pub fn qa_background() -> bool {
39 std::env::var_os("RIGHTKIT_QA_BACKGROUND").as_deref() == Some(std::ffi::OsStr::new("1"))
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum RevealSource {
45 Reopen,
47 Opened,
49 SecondInstance,
51 ShowOnReady,
53 Tray,
55 WindowCommand,
57 OpenWindow,
59 Restore,
61 Api,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum RevealMode {
68 Forbidden,
70 Background,
72 Foreground,
74}
75
76pub const NEVER_SHOW_TRUTHY: [&str; 4] = ["1", "true", "yes", "on"];
79
80pub fn env_means_never_show(value: Option<&OsStr>) -> bool {
84 let Some(value) = value.and_then(|v| v.to_str()) else {
85 return false;
86 };
87 NEVER_SHOW_TRUTHY
88 .iter()
89 .any(|truthy| value.eq_ignore_ascii_case(truthy))
90}
91
92pub fn env_equals(value: Option<&OsStr>, expected: &str) -> bool {
94 value.is_some_and(|v| v == OsStr::new(expected))
95}
96
97#[derive(Debug, Clone)]
99pub struct ShowPolicy {
100 pub never_show: bool,
103 pub never_show_env: Vec<String>,
105 pub never_show_env_values: Vec<(String, String)>,
108 pub reveal_on_reopen: bool,
110}
111
112impl Default for ShowPolicy {
113 fn default() -> Self {
114 Self {
115 never_show: false,
116 never_show_env: Vec::new(),
117 never_show_env_values: Vec::new(),
118 reveal_on_reopen: true,
119 }
120 }
121}
122
123impl ShowPolicy {
124 pub fn is_never_show(&self, env: &dyn Fn(&str) -> Option<OsString>) -> bool {
126 self.never_show
127 || self
128 .never_show_env
129 .iter()
130 .any(|name| env_means_never_show(env(name).as_deref()))
131 || self
132 .never_show_env_values
133 .iter()
134 .any(|(name, expected)| env_equals(env(name).as_deref(), expected))
135 }
136
137 pub fn never_show_now(&self) -> bool {
139 self.is_never_show(&|name: &str| std::env::var_os(name))
140 }
141
142 pub fn mode(
144 &self,
145 source: RevealSource,
146 qa_background: bool,
147 env: &dyn Fn(&str) -> Option<OsString>,
148 ) -> RevealMode {
149 if self.is_never_show(env) {
150 return RevealMode::Forbidden;
151 }
152 if matches!(source, RevealSource::Reopen | RevealSource::Opened) && !self.reveal_on_reopen {
153 return RevealMode::Forbidden;
154 }
155 if qa_background {
156 RevealMode::Background
157 } else {
158 RevealMode::Foreground
159 }
160 }
161
162 pub fn mode_now(&self, source: RevealSource) -> RevealMode {
164 self.mode(source, qa_background(), &|name: &str| {
165 std::env::var_os(name)
166 })
167 }
168}
169
170pub fn reveal_mode<R: Runtime>(app: &AppHandle<R>, source: RevealSource) -> RevealMode {
175 match app.try_state::<ShellState>() {
176 Some(state) => state.show_policy.mode_now(source),
177 None => ShowPolicy::default().mode_now(source),
178 }
179}
180
181pub fn never_show<R: Runtime>(app: &AppHandle<R>) -> bool {
183 app.try_state::<ShellState>()
184 .is_some_and(|state| state.show_policy.never_show_now())
185}
186
187pub fn reopen_wants_reveal(has_visible_windows: bool) -> bool {
189 !has_visible_windows
190}
191
192#[cfg(target_os = "macos")]
195pub(crate) fn enforce_never_show(app: &AppHandle<Wry>, policy: &ShowPolicy) {
196 if policy.never_show_now() {
197 let _ = app.set_activation_policy(tauri::ActivationPolicy::Prohibited);
198 }
199}
200
201#[cfg(not(target_os = "macos"))]
202pub(crate) fn enforce_never_show(_app: &AppHandle<Wry>, _policy: &ShowPolicy) {}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum InstanceMode {
207 Disabled,
209 Arbitrate,
211 QaExempt,
215}
216
217pub fn instance_mode(single_instance: bool, qa_exempt: bool, qa_hidden_env: bool) -> InstanceMode {
219 match (single_instance, qa_exempt && qa_hidden_env) {
220 (false, _) => InstanceMode::Disabled,
221 (true, true) => InstanceMode::QaExempt,
222 (true, false) => InstanceMode::Arbitrate,
223 }
224}
225
226pub fn show_on_ready_decision(
232 explicit: Option<bool>,
233 module_enabled: bool,
234 background_arg: bool,
235 qa_hidden_env: bool,
236) -> bool {
237 if !module_enabled || background_arg {
238 return false;
239 }
240 explicit.unwrap_or(!qa_hidden_env)
241}
242
243pub fn show_on_ready_installed(
247 module_in_set: bool,
248 lifecycle_only: bool,
249 explicit: Option<bool>,
250) -> bool {
251 module_in_set || (lifecycle_only && explicit == Some(true))
252}
253
254pub fn repair_cache_enabled(explicit: Option<bool>, lifecycle_only: bool) -> bool {
257 explicit.unwrap_or(!lifecycle_only)
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub enum Visibility {
263 Hidden,
264 Revealed,
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum Activation {
270 Regular,
272 Accessory,
274}
275
276pub fn activation_for(
279 hide_to_background: bool,
280 visibility: Visibility,
281 other_visible: bool,
282) -> Option<Activation> {
283 if !hide_to_background {
284 return None;
285 }
286 match visibility {
287 Visibility::Hidden if other_visible => None,
288 Visibility::Hidden => Some(Activation::Accessory),
289 Visibility::Revealed => Some(Activation::Regular),
290 }
291}
292
293#[derive(Debug, Default)]
295pub struct ActivationTracker {
296 accessory: AtomicBool,
297}
298
299impl ActivationTracker {
300 pub fn transition(&self, want: Option<Activation>) -> Option<Activation> {
302 match want? {
303 Activation::Accessory => {
304 (!self.accessory.swap(true, Ordering::SeqCst)).then_some(Activation::Accessory)
305 }
306 Activation::Regular => self
307 .accessory
308 .swap(false, Ordering::SeqCst)
309 .then_some(Activation::Regular),
310 }
311 }
312}
313
314pub(crate) fn note_visibility(app: &AppHandle<Wry>, visibility: Visibility) {
317 let state = app.state::<ShellState>();
318 let other_visible = app
319 .webview_windows()
320 .iter()
321 .any(|(label, w)| *label != state.main_label && w.is_visible().unwrap_or(false));
322 let want = if state.show_policy.never_show_now() {
324 None
325 } else {
326 activation_for(state.hide_to_background, visibility, other_visible)
327 };
328 if let Some(change) = state.activation.transition(want) {
329 apply_activation(app, change);
330 }
331 if let Some(cb) = &state.on_visibility {
332 cb(app, visibility);
333 }
334}
335
336#[cfg(target_os = "macos")]
337fn apply_activation(app: &AppHandle<Wry>, change: Activation) {
338 let policy = match change {
339 Activation::Regular => tauri::ActivationPolicy::Regular,
340 Activation::Accessory => tauri::ActivationPolicy::Accessory,
341 };
342 let _ = app.set_activation_policy(policy);
343}
344
345#[cfg(not(target_os = "macos"))]
346fn apply_activation(_app: &AppHandle<Wry>, _change: Activation) {}
347
348pub(crate) fn request_quit(app: &AppHandle<Wry>, source: &'static str) {
350 let state = app.state::<ShellState>();
351 let alive = !app.webview_windows().is_empty();
352 match state.quit.request(&state.exiting, alive) {
353 QuitStep::ExitNow => quit(app),
354 QuitStep::AlreadyDraining => {}
355 QuitStep::Drain => {
356 if !start_drain(app, source) {
357 state.quit.finish();
358 quit(app);
359 }
360 }
361 }
362}
363
364fn start_drain(app: &AppHandle<Wry>, source: &'static str) -> bool {
366 let state = app.state::<ShellState>();
367 let Some(bound) = state.quit.bound() else {
368 return false;
369 };
370 let payload = serde_json::json!({"source": source, "boundMs": bound.as_millis() as u64});
371 if app.emit(EVENT_QUIT_REQUESTED, payload).is_err() {
372 return false;
373 }
374 let handle = app.clone();
375 state.quit.arm_bound(move || quit(&handle));
376 true
377}
378
379pub(crate) fn finish_quit(app: &AppHandle<Wry>) {
381 let state = app.state::<ShellState>();
382 state.exiting.store(true, Ordering::SeqCst);
384 state.quit.finish();
385 quit(app);
386}
387
388pub fn quit(app: &AppHandle<Wry>) {
389 app.state::<ShellState>()
390 .exiting
391 .store(true, Ordering::SeqCst);
392 app.exit(0);
393}
394
395pub(crate) fn deliver_links<'a>(app: &AppHandle<Wry>, raw: impl IntoIterator<Item = &'a str>) {
396 let state = app.state::<ShellState>();
397 let raw: Vec<&str> = raw.into_iter().collect();
398 crate::open_url::deliver(&raw);
399 state.deep.push_all(raw, &state.schemes);
400 flush_links(app);
401}
402
403pub(crate) fn flush_links(app: &AppHandle<Wry>) {
404 let state = app.state::<ShellState>();
405 if !state.frontend_ready.load(Ordering::SeqCst) {
406 return;
407 }
408 for link in state.deep.drain() {
409 let _ = app.emit(EVENT_DEEP_LINK, link);
410 }
411}
412
413pub(crate) type SecondInstanceFn<A> = std::sync::Arc<dyn Fn(&A, &[String], &str) + Send + Sync>;
415
416pub(crate) fn route_second_instance<A>(
419 app: &A,
420 custom: Option<&SecondInstanceFn<A>>,
421 argv: &[String],
422 cwd: &str,
423 default: impl Fn(&A, &[String], &str),
424) {
425 crate::open_url::deliver_second_instance(argv);
426 match custom {
427 Some(f) => f(app, argv, cwd),
428 None => default(app, argv, cwd),
429 }
430}
431
432pub(crate) type RevealDecider<A> =
434 std::sync::Arc<dyn Fn(&A, &[String], &str) -> bool + Send + Sync>;
435
436pub(crate) fn second_instance_reveals<A>(
438 decider: Option<&RevealDecider<A>>,
439 app: &A,
440 argv: &[String],
441 cwd: &str,
442) -> bool {
443 decider.is_none_or(|f| f(app, argv, cwd))
444}
445
446pub(crate) fn on_second_instance(
447 app: &AppHandle<Wry>,
448 argv: &[String],
449 cwd: &str,
450 decider: Option<&RevealDecider<AppHandle<Wry>>>,
451) {
452 let _ = app.emit(
453 EVENT_SECOND_INSTANCE,
454 serde_json::json!({"argv": argv, "cwd": cwd}),
455 );
456 deliver_links(app, argv.iter().skip(1).map(String::as_str));
457 if second_instance_reveals(decider, app, argv, cwd) {
458 crate::windows::reveal_with(app, None, RevealSource::SecondInstance);
459 }
460}
461
462pub(crate) fn on_menu_event(app: &AppHandle<Wry>, id: &str) {
463 let state = app.state::<ShellState>();
464 let _ = app.emit(EVENT_MENU, serde_json::json!({"id": id}));
465 if let Some(cb) = &state.on_menu {
466 cb(app, id);
467 }
468}
469
470fn note_minimized_for_idle(app: &AppHandle<Wry>, label: &str) {
473 let state = app.state::<ShellState>();
474 if label != state.main_label
475 || !state
476 .idle
477 .as_ref()
478 .is_some_and(|h| h.options.start_on_minimize)
479 {
480 return;
481 }
482 if let Some(w) = app.get_webview_window(label) {
483 crate::idle::note_minimized(app, w.is_minimized().unwrap_or(false));
484 }
485}
486
487pub(crate) fn run_event(app: &AppHandle<Wry>, event: &RunEvent) {
488 let state = app.state::<ShellState>();
489 match event {
490 RunEvent::WindowEvent { label, event, .. } => match event {
491 WindowEvent::Moved(_) | WindowEvent::Resized(_) => {
492 if state.modules.contains(crate::Module::WindowState) {
493 state.windows.schedule_save(app.clone(), label.clone());
494 }
495 if matches!(event, WindowEvent::Resized(_)) {
496 if let Some(w) = app.get_webview_window(label) {
497 if state.modules.contains(crate::Module::Frameless) {
498 state.traffic.reapply(&w);
499 }
500 }
501 note_minimized_for_idle(app, label);
502 }
503 }
504 WindowEvent::Focused(false) => note_minimized_for_idle(app, label),
505 WindowEvent::Focused(true) => {
506 if let Some(w) = app.get_webview_window(label) {
507 if state.modules.contains(crate::Module::Frameless) {
508 state.traffic.reapply(&w);
509 }
510 }
511 if *label == state.main_label {
512 crate::idle::note_visible(app);
513 }
514 }
515 WindowEvent::CloseRequested { api, .. } => {
516 if state.modules.contains(crate::Module::WindowState) {
517 let _ = state.windows.save_now(app, label);
518 }
519 if state.close_to_tray
520 && *label == state.main_label
521 && !state.exiting.load(Ordering::SeqCst)
522 {
523 api.prevent_close();
524 if let Some(w) = app.get_webview_window(label) {
525 let _ = w.hide();
526 }
527 crate::idle::note_hidden(app, true);
528 note_visibility(app, Visibility::Hidden);
529 }
530 }
531 _ => {}
532 },
533 RunEvent::ExitRequested { api, code, .. } => {
534 if code.is_none() && state.close_to_tray && !state.exiting.load(Ordering::SeqCst) {
536 api.prevent_exit();
537 return;
538 }
539 match state
542 .quit
543 .request(&state.exiting, !app.webview_windows().is_empty())
544 {
545 QuitStep::AlreadyDraining => {
546 api.prevent_exit();
547 return;
548 }
549 QuitStep::Drain => {
550 if start_drain(app, "exit-requested") {
551 api.prevent_exit();
552 return;
553 }
554 state.quit.finish();
555 }
556 QuitStep::ExitNow => {}
557 }
558 state.exiting.store(true, Ordering::SeqCst);
559 for w in app.webview_windows().keys() {
560 if state.modules.contains(crate::Module::WindowState) {
561 let _ = state.windows.save_now(app, w);
562 }
563 }
564 }
565 RunEvent::Exit => shutdown(app),
566 #[cfg(target_os = "macos")]
567 RunEvent::Reopen {
568 has_visible_windows,
569 ..
570 } => {
571 if reopen_wants_reveal(*has_visible_windows) {
572 crate::windows::reveal_with(app, None, RevealSource::Reopen);
573 }
574 }
575 #[cfg(any(target_os = "macos", target_os = "ios"))]
576 RunEvent::Opened { urls } => {
577 let raw: Vec<String> = urls.iter().map(|u| u.to_string()).collect();
578 deliver_links(app, raw.iter().map(String::as_str));
579 crate::windows::reveal_with(app, None, RevealSource::Opened);
580 }
581 _ => {}
582 }
583}
584
585fn shutdown(app: &AppHandle<Wry>) {
586 let state = app.state::<ShellState>();
587 state.exiting.store(true, Ordering::SeqCst);
588 #[cfg(feature = "engine")]
589 if let Some(engine) = &state.engine {
590 engine.shutdown();
591 }
592 if state.tray.is_some() {
593 let _ = app.remove_tray_by_id(crate::tray::TRAY_ID);
594 }
595 crate::single_instance::release_all();
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602 use std::cell::Cell;
603 use std::sync::{Arc, Mutex};
604
605 type Calls = Arc<Mutex<Vec<(Vec<String>, String)>>>;
606
607 #[test]
608 fn default_runs_without_a_callback() {
609 let ran = Cell::new(0);
610 let argv = vec!["app".to_owned()];
611 route_second_instance(&(), None, &argv, "/w", |_, a, c| {
612 assert_eq!((a, c), (&argv[..], "/w"));
613 ran.set(ran.get() + 1);
614 });
615 assert_eq!(ran.get(), 1);
616 }
617
618 #[test]
619 fn custom_callback_gets_argv_and_cwd_and_suppresses_default() {
620 let calls: Calls = Arc::default();
621 let c = calls.clone();
622 let cb: SecondInstanceFn<()> =
623 Arc::new(move |_, a, cwd| c.lock().unwrap().push((a.to_vec(), cwd.to_owned())));
624 let argv = vec!["app".to_owned(), "--x".to_owned()];
625 route_second_instance(&(), Some(&cb), &argv, "/work", |_, _, _| {
626 panic!("default focus behaviour must not run")
627 });
628 assert_eq!(*calls.lock().unwrap(), vec![(argv, "/work".to_owned())]);
629 }
630
631 #[test]
632 fn the_default_second_launch_reveals_unless_the_host_declines() {
633 let argv = vec!["app".to_owned()];
634 assert!(second_instance_reveals::<()>(None, &(), &argv, "/w"));
635 let yes: RevealDecider<()> = Arc::new(|_, _, _| true);
636 let no: RevealDecider<()> = Arc::new(|_, a, cwd| a.len() > 5 && cwd == "/w");
637 assert!(second_instance_reveals(Some(&yes), &(), &argv, "/w"));
638 assert!(!second_instance_reveals(Some(&no), &(), &argv, "/w"));
639 }
640}