Skip to main content

rightkit_tauri_shell/
lifecycle.rs

1//! Startup / shutdown order and RunEvent routing.
2//!
3//! Startup (plugin `setup`, then Tauri creates windows):
4//!
5//! 1. single-instance arbitration (a second launch forwards argv and exits before anything heavy)
6//! 2. shared state managed, 3. native menu, 4. tray
7//! 5. engine started off the UI thread (never blocks first paint), 6. watchdog
8//! 7. each webview becomes ready: native hardening, saved geometry restored, window shown
9//!    (unless launched with `--background`)
10//! 8. deep links queued from argv are delivered when the frontend calls `rk_shell_ready`
11//!
12//! Shutdown (`quit` -> `ExitRequested` -> `Exit`): mark exiting (new engine calls refused), save
13//! window geometry, stop the engine (bounded), remove the tray, release the single-instance
14//! service.
15//!
16//! Donors: HeardRight `tray.rs` Quit (engine shutdown before exit), `hub_window` (close hides,
17//! Dock reopen restores), ViewRight `RunEvent::Reopen`, CodeRight `window_state` save hooks.
18
19use 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
27/// True when this launch asked to stay in the tray (`--background`).
28pub fn launched_in_background() -> bool {
29    std::env::args().any(|a| a == crate::autostart::BACKGROUND_ARG)
30}
31
32/// QA-only launch policy. The harness sets this before the frontend exists so every
33/// shell-owned show/reopen path can preserve the user's foreground app. Compiled out of
34/// product releases: without debug assertions the selector is not read at all and the answer
35/// is always `false`, so a shipped binary carries no QA hidden/background switch.
36#[cfg(debug_assertions)]
37pub fn qa_hidden() -> bool {
38    std::env::var_os("RIGHTKIT_QA_HIDDEN").as_deref() == Some(std::ffi::OsStr::new("1"))
39}
40
41#[cfg(not(debug_assertions))]
42pub fn qa_hidden() -> bool {
43    false
44}
45
46#[cfg(debug_assertions)]
47pub fn qa_background() -> bool {
48    std::env::var_os("RIGHTKIT_QA_BACKGROUND").as_deref() == Some(std::ffi::OsStr::new("1"))
49}
50
51#[cfg(not(debug_assertions))]
52pub fn qa_background() -> bool {
53    false
54}
55
56/// Why the shell is bringing a window up. Every source asks the same guard ([`reveal_mode`]).
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub enum RevealSource {
59    /// macOS Dock reopen (`RunEvent::Reopen`).
60    Reopen,
61    /// The OS handed the app a URL or file (`RunEvent::Opened`).
62    Opened,
63    /// A second launch forwarded to the primary.
64    SecondInstance,
65    /// The main webview became ready.
66    ShowOnReady,
67    /// Tray menu "Show" or a tray click.
68    Tray,
69    /// The `rk_shell_window` `show` command from the renderer.
70    WindowCommand,
71    /// `windows::open` (create or re-reveal a secondary window).
72    OpenWindow,
73    /// `WindowStateStore::restore_with` visibility options.
74    Restore,
75    /// A host call to `windows::reveal`.
76    Api,
77}
78
79/// What the guard allows for one reveal.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum RevealMode {
82    /// Nothing: no show, no unminimise, no focus, no activation.
83    Forbidden,
84    /// QA background: native order-back only; never unminimise or focus.
85    Background,
86    /// Ordinary production reveal.
87    Foreground,
88}
89
90/// The documented truthy values for a never-show environment variable, compared exactly
91/// (ASCII case-insensitive, no trimming).
92pub const NEVER_SHOW_TRUTHY: [&str; 4] = ["1", "true", "yes", "on"];
93
94/// True when an environment value means "never show": exactly one of [`NEVER_SHOW_TRUTHY`]
95/// (`1`, `true`, `yes`, `on`), ASCII case-insensitive. Anything else (unset, empty, `0`, padded or
96/// unrecognised text) is off.
97pub fn env_means_never_show(value: Option<&OsStr>) -> bool {
98    let Some(value) = value.and_then(|v| v.to_str()) else {
99        return false;
100    };
101    NEVER_SHOW_TRUTHY
102        .iter()
103        .any(|truthy| value.eq_ignore_ascii_case(truthy))
104}
105
106/// True when the value is exactly `expected` (case-sensitive, no trimming).
107pub fn env_equals(value: Option<&OsStr>, expected: &str) -> bool {
108    value.is_some_and(|v| v == OsStr::new(expected))
109}
110
111/// The host's say over window visibility, evaluated by [`ShowPolicy::mode`].
112#[derive(Debug, Clone)]
113pub struct ShowPolicy {
114    /// Host-declared: the shell never shows, unminimises or focuses a window, and on macOS the app
115    /// is a Prohibited-activation app.
116    pub never_show: bool,
117    /// Extra environment variables that mean never-show when set to a truthy value (see [`env_means_never_show`]).
118    pub never_show_env: Vec<String>,
119    /// Environment variables that mean never-show only when set to exactly the paired value
120    /// (see [`env_equals`]), for a harness whose variable uses its own token.
121    pub never_show_env_values: Vec<(String, String)>,
122    /// Whether `Reopen` and `Opened` may reveal at all (also needs the module).
123    pub reveal_on_reopen: bool,
124}
125
126impl Default for ShowPolicy {
127    fn default() -> Self {
128        Self {
129            never_show: false,
130            never_show_env: Vec::new(),
131            never_show_env_values: Vec::new(),
132            reveal_on_reopen: true,
133        }
134    }
135}
136
137impl ShowPolicy {
138    /// True when never-show is declared by the host or by any registered environment variable.
139    pub fn is_never_show(&self, env: &dyn Fn(&str) -> Option<OsString>) -> bool {
140        self.never_show
141            || self
142                .never_show_env
143                .iter()
144                .any(|name| env_means_never_show(env(name).as_deref()))
145            || self
146                .never_show_env_values
147                .iter()
148                .any(|(name, expected)| env_equals(env(name).as_deref(), expected))
149    }
150
151    /// [`ShowPolicy::is_never_show`] against the process environment.
152    pub fn never_show_now(&self) -> bool {
153        self.is_never_show(&|name: &str| std::env::var_os(name))
154    }
155
156    /// The one decision. Never-show beats everything; the reopen opt-out beats QA mode.
157    pub fn mode(
158        &self,
159        source: RevealSource,
160        qa_background: bool,
161        env: &dyn Fn(&str) -> Option<OsString>,
162    ) -> RevealMode {
163        if self.is_never_show(env) {
164            return RevealMode::Forbidden;
165        }
166        if matches!(source, RevealSource::Reopen | RevealSource::Opened) && !self.reveal_on_reopen {
167            return RevealMode::Forbidden;
168        }
169        if qa_background {
170            RevealMode::Background
171        } else {
172            RevealMode::Foreground
173        }
174    }
175
176    /// [`ShowPolicy::mode`] against the process environment and `RIGHTKIT_QA_BACKGROUND`.
177    pub fn mode_now(&self, source: RevealSource) -> RevealMode {
178        self.mode(source, qa_background(), &|name: &str| {
179            std::env::var_os(name)
180        })
181    }
182}
183
184/// THE guard. Every path that shows, unminimises or focuses a window (reopen, opened, second
185/// instance, show-on-ready, tray, the window command, `windows::open`, `windows::reveal`, restore)
186/// asks this function and does exactly what the answer allows. Without `ShellState` (a standalone
187/// helper outside the plugin) the default policy applies.
188pub fn reveal_mode<R: Runtime>(app: &AppHandle<R>, source: RevealSource) -> RevealMode {
189    match app.try_state::<ShellState>() {
190        Some(state) => state.show_policy.mode_now(source),
191        None => ShowPolicy::default().mode_now(source),
192    }
193}
194
195/// True when the host forbade showing windows (builder flag or registered environment variable).
196pub fn never_show<R: Runtime>(app: &AppHandle<R>) -> bool {
197    app.try_state::<ShellState>()
198        .is_some_and(|state| state.show_policy.never_show_now())
199}
200
201/// A Dock reopen with no visible window is the only reopen that asks to reveal.
202pub fn reopen_wants_reveal(has_visible_windows: bool) -> bool {
203    !has_visible_windows
204}
205
206/// macOS: make the app Prohibited (no Dock icon, no menu bar, never frontmost) when never-show
207/// applies. Idempotent; called at plugin setup and again when each webview is ready.
208#[cfg(target_os = "macos")]
209pub(crate) fn enforce_never_show(app: &AppHandle<Wry>, policy: &ShowPolicy) {
210    if policy.never_show_now() {
211        let _ = app.set_activation_policy(tauri::ActivationPolicy::Prohibited);
212    }
213}
214
215#[cfg(not(target_os = "macos"))]
216pub(crate) fn enforce_never_show(_app: &AppHandle<Wry>, _policy: &ShowPolicy) {}
217
218/// How a launch takes part in single-instance arbitration.
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum InstanceMode {
221    /// The host turned single instance off: no claim, no forward.
222    Disabled,
223    /// Claim the primary, or forward to it and exit (the default).
224    Arbitrate,
225    /// A hidden QA launch (`RIGHTKIT_QA_HIDDEN=1`) of an app that opted in with
226    /// `qa_instance_exempt`: it neither claims the primary nor forwards to the running instance, so
227    /// it can never be revealed by, or reveal, the user's own copy.
228    QaExempt,
229}
230
231/// Pure decision for [`InstanceMode`].
232pub fn instance_mode(single_instance: bool, qa_exempt: bool, qa_hidden_env: bool) -> InstanceMode {
233    match (single_instance, qa_exempt && qa_hidden_env) {
234        (false, _) => InstanceMode::Disabled,
235        (true, true) => InstanceMode::QaExempt,
236        (true, false) => InstanceMode::Arbitrate,
237    }
238}
239
240/// Whether the main window is shown when its webview is ready.
241///
242/// `explicit` is what the host passed to `show_on_ready` (`None`: never called). An explicit choice
243/// is the host's visibility decision, so `RIGHTKIT_QA_HIDDEN` only decides when the host said
244/// nothing. A login launch (`--background`) and a disabled module always keep the window hidden.
245pub fn show_on_ready_decision(
246    explicit: Option<bool>,
247    module_enabled: bool,
248    background_arg: bool,
249    qa_hidden_env: bool,
250) -> bool {
251    if !module_enabled || background_arg {
252        return false;
253    }
254    explicit.unwrap_or(!qa_hidden_env)
255}
256
257/// Whether the `ShowOnReady` behaviour is installed. `lifecycle_only` turns the module off, and
258/// only the host's explicit `show_on_ready(true)` brings it back; every other module set is
259/// decided by the module alone.
260pub fn show_on_ready_installed(
261    module_in_set: bool,
262    lifecycle_only: bool,
263    explicit: Option<bool>,
264) -> bool {
265    module_in_set || (lifecycle_only && explicit == Some(true))
266}
267
268/// Whether the startup cache repair runs: the host's explicit choice, else on, except under
269/// `lifecycle_only` where the host owns its cache.
270pub fn repair_cache_enabled(explicit: Option<bool>, lifecycle_only: bool) -> bool {
271    explicit.unwrap_or(!lifecycle_only)
272}
273
274/// What happened to the main window.
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum Visibility {
277    Hidden,
278    Revealed,
279}
280
281/// macOS activation policy, mirrored so the decision is testable on every host.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum Activation {
284    /// Dock icon and menu bar (a normal app).
285    Regular,
286    /// No Dock icon, no menu bar: a background app.
287    Accessory,
288}
289
290/// The activation policy a hide or reveal asks for, when the host chose "hide to background".
291/// `other_visible`: some other window is still on screen, so the app must stay in the Dock.
292pub fn activation_for(
293    hide_to_background: bool,
294    visibility: Visibility,
295    other_visible: bool,
296) -> Option<Activation> {
297    if !hide_to_background {
298        return None;
299    }
300    match visibility {
301        Visibility::Hidden if other_visible => None,
302        Visibility::Hidden => Some(Activation::Accessory),
303        Visibility::Revealed => Some(Activation::Regular),
304    }
305}
306
307/// Remembers the policy last applied so only real changes reach the OS.
308#[derive(Debug, Default)]
309pub struct ActivationTracker {
310    accessory: AtomicBool,
311}
312
313impl ActivationTracker {
314    /// The change to apply for `want`, or `None` when it is no change.
315    pub fn transition(&self, want: Option<Activation>) -> Option<Activation> {
316        match want? {
317            Activation::Accessory => {
318                (!self.accessory.swap(true, Ordering::SeqCst)).then_some(Activation::Accessory)
319            }
320            Activation::Regular => self
321                .accessory
322                .swap(false, Ordering::SeqCst)
323                .then_some(Activation::Regular),
324        }
325    }
326}
327
328/// Called after the main window is hidden or revealed: apply the macOS activation policy for a
329/// "hide to background" host, then run the host's visibility callback.
330pub(crate) fn note_visibility(app: &AppHandle<Wry>, visibility: Visibility) {
331    let state = app.state::<ShellState>();
332    let other_visible = app
333        .webview_windows()
334        .iter()
335        .any(|(label, w)| *label != state.main_label && w.is_visible().unwrap_or(false));
336    // A never-show host stays Prohibited: hide/reveal never changes the activation policy.
337    let want = if state.show_policy.never_show_now() {
338        None
339    } else {
340        activation_for(state.hide_to_background, visibility, other_visible)
341    };
342    if let Some(change) = state.activation.transition(want) {
343        apply_activation(app, change);
344    }
345    if let Some(cb) = &state.on_visibility {
346        cb(app, visibility);
347    }
348}
349
350#[cfg(target_os = "macos")]
351fn apply_activation(app: &AppHandle<Wry>, change: Activation) {
352    let policy = match change {
353        Activation::Regular => tauri::ActivationPolicy::Regular,
354        Activation::Accessory => tauri::ActivationPolicy::Accessory,
355    };
356    let _ = app.set_activation_policy(policy);
357}
358
359#[cfg(not(target_os = "macos"))]
360fn apply_activation(_app: &AppHandle<Wry>, _change: Activation) {}
361
362/// Begin a quit from a host-side request (tray Quit): drain when the host opted in, else exit now.
363pub(crate) fn request_quit(app: &AppHandle<Wry>, source: &'static str) {
364    let state = app.state::<ShellState>();
365    let alive = !app.webview_windows().is_empty();
366    match state.quit.request(&state.exiting, alive) {
367        QuitStep::ExitNow => quit(app),
368        QuitStep::AlreadyDraining => {}
369        QuitStep::Drain => {
370            if !start_drain(app, source) {
371                state.quit.finish();
372                quit(app);
373            }
374        }
375    }
376}
377
378/// Emit `quit-requested` and arm the bound. False when the renderer cannot be reached.
379fn start_drain(app: &AppHandle<Wry>, source: &'static str) -> bool {
380    let state = app.state::<ShellState>();
381    let Some(bound) = state.quit.bound() else {
382        return false;
383    };
384    let payload = serde_json::json!({"source": source, "boundMs": bound.as_millis() as u64});
385    if app.emit(EVENT_QUIT_REQUESTED, payload).is_err() {
386        return false;
387    }
388    let handle = app.clone();
389    state.quit.arm_bound(move || quit(&handle));
390    true
391}
392
393/// The renderer finished draining (or never will): exit.
394pub(crate) fn finish_quit(app: &AppHandle<Wry>) {
395    let state = app.state::<ShellState>();
396    // `exiting` first: a quit request racing this one must see ExitNow, not start a new drain.
397    state.exiting.store(true, Ordering::SeqCst);
398    state.quit.finish();
399    quit(app);
400}
401
402pub fn quit(app: &AppHandle<Wry>) {
403    app.state::<ShellState>()
404        .exiting
405        .store(true, Ordering::SeqCst);
406    app.exit(0);
407}
408
409pub(crate) fn deliver_links<'a>(app: &AppHandle<Wry>, raw: impl IntoIterator<Item = &'a str>) {
410    let state = app.state::<ShellState>();
411    let raw: Vec<&str> = raw.into_iter().collect();
412    crate::open_url::deliver(&raw);
413    state.deep.push_all(raw, &state.schemes);
414    flush_links(app);
415}
416
417pub(crate) fn flush_links(app: &AppHandle<Wry>) {
418    let state = app.state::<ShellState>();
419    if !state.frontend_ready.load(Ordering::SeqCst) {
420        return;
421    }
422    for link in state.deep.drain() {
423        let _ = app.emit(EVENT_DEEP_LINK, link);
424    }
425}
426
427/// An app's second-instance callback: (app, argv, cwd).
428pub(crate) type SecondInstanceFn<A> = std::sync::Arc<dyn Fn(&A, &[String], &str) + Send + Sync>;
429
430/// Second-launch routing: URLs reach the open-URL hub first, then the app's callback owns the
431/// rest; without one the `default` (event, deep links, reveal) runs.
432pub(crate) fn route_second_instance<A>(
433    app: &A,
434    custom: Option<&SecondInstanceFn<A>>,
435    argv: &[String],
436    cwd: &str,
437    default: impl Fn(&A, &[String], &str),
438) {
439    crate::open_url::deliver_second_instance(argv);
440    match custom {
441        Some(f) => f(app, argv, cwd),
442        None => default(app, argv, cwd),
443    }
444}
445
446/// A host's say over whether a second launch reveals the main window: (app, argv, cwd) -> reveal.
447pub(crate) type RevealDecider<A> =
448    std::sync::Arc<dyn Fn(&A, &[String], &str) -> bool + Send + Sync>;
449
450/// True when the default second-launch behaviour should reveal and focus the main window.
451pub(crate) fn second_instance_reveals<A>(
452    decider: Option<&RevealDecider<A>>,
453    app: &A,
454    argv: &[String],
455    cwd: &str,
456) -> bool {
457    decider.is_none_or(|f| f(app, argv, cwd))
458}
459
460pub(crate) fn on_second_instance(
461    app: &AppHandle<Wry>,
462    argv: &[String],
463    cwd: &str,
464    decider: Option<&RevealDecider<AppHandle<Wry>>>,
465) {
466    let _ = app.emit(
467        EVENT_SECOND_INSTANCE,
468        serde_json::json!({"argv": argv, "cwd": cwd}),
469    );
470    deliver_links(app, argv.iter().skip(1).map(String::as_str));
471    if second_instance_reveals(decider, app, argv, cwd) {
472        crate::windows::reveal_with(app, None, RevealSource::SecondInstance);
473    }
474}
475
476pub(crate) fn on_menu_event(app: &AppHandle<Wry>, id: &str) {
477    if id == crate::menu::ID_QUIT {
478        request_quit(app, "menu");
479        return;
480    }
481    let state = app.state::<ShellState>();
482    let _ = app.emit(EVENT_MENU, serde_json::json!({"id": id}));
483    if let Some(cb) = &state.on_menu {
484        cb(app, id);
485    }
486}
487
488/// Feed the idle machine a minimise or restore of the main window (only for a host that asked with
489/// `IdleOptions::start_on_minimize` or `IdleTrigger::Minimised`).
490fn note_minimized_for_idle(app: &AppHandle<Wry>, label: &str) {
491    let state = app.state::<ShellState>();
492    if label != state.main_label
493        || !state
494            .idle
495            .as_ref()
496            .is_some_and(|h| h.options.minimise_enabled())
497    {
498        return;
499    }
500    if let Some(w) = app.get_webview_window(label) {
501        crate::idle::note_minimized(app, w.is_minimized().unwrap_or(false));
502    }
503}
504
505pub(crate) fn run_event(app: &AppHandle<Wry>, event: &RunEvent) {
506    let state = app.state::<ShellState>();
507    match event {
508        RunEvent::WindowEvent { label, event, .. } => match event {
509            WindowEvent::Moved(_) | WindowEvent::Resized(_) => {
510                if state.modules.contains(crate::Module::WindowState) {
511                    state.windows.schedule_save(app.clone(), label.clone());
512                }
513                if matches!(event, WindowEvent::Resized(_)) {
514                    if let Some(w) = app.get_webview_window(label) {
515                        if state.modules.contains(crate::Module::Frameless) {
516                            state.traffic.reapply(&w);
517                        }
518                    }
519                    note_minimized_for_idle(app, label);
520                }
521            }
522            WindowEvent::Focused(false) => note_minimized_for_idle(app, label),
523            WindowEvent::Focused(true) => {
524                if let Some(w) = app.get_webview_window(label) {
525                    if state.modules.contains(crate::Module::Frameless) {
526                        state.traffic.reapply(&w);
527                    }
528                }
529                if *label == state.main_label {
530                    crate::idle::note_visible(app);
531                }
532            }
533            WindowEvent::CloseRequested { api, .. } => {
534                if state.modules.contains(crate::Module::WindowState) {
535                    let _ = state.windows.save_now(app, label);
536                }
537                if state.close_to_tray
538                    && *label == state.main_label
539                    && !state.exiting.load(Ordering::SeqCst)
540                {
541                    api.prevent_close();
542                    if let Some(w) = app.get_webview_window(label) {
543                        let _ = w.hide();
544                    }
545                    crate::idle::note_hidden(app, true);
546                    note_visibility(app, Visibility::Hidden);
547                }
548            }
549            _ => {}
550        },
551        RunEvent::ExitRequested { api, code, .. } => {
552            // `code == None`: the last window closed. A tray-resident app keeps running.
553            if code.is_none() && state.close_to_tray && !state.exiting.load(Ordering::SeqCst) {
554                api.prevent_exit();
555                return;
556            }
557            // Opt-in drain: ask the renderer to flush first. `exiting` set by finish/force/bound
558            // makes this return ExitNow, so the exit that follows never prompts twice.
559            match state
560                .quit
561                .request(&state.exiting, !app.webview_windows().is_empty())
562            {
563                QuitStep::AlreadyDraining => {
564                    api.prevent_exit();
565                    return;
566                }
567                QuitStep::Drain => {
568                    if start_drain(app, "exit-requested") {
569                        api.prevent_exit();
570                        return;
571                    }
572                    state.quit.finish();
573                }
574                QuitStep::ExitNow => {}
575            }
576            state.exiting.store(true, Ordering::SeqCst);
577            for w in app.webview_windows().keys() {
578                if state.modules.contains(crate::Module::WindowState) {
579                    let _ = state.windows.save_now(app, w);
580                }
581            }
582        }
583        RunEvent::Exit => shutdown(app),
584        #[cfg(target_os = "macos")]
585        RunEvent::Reopen {
586            has_visible_windows,
587            ..
588        } => {
589            if reopen_wants_reveal(*has_visible_windows) {
590                crate::windows::reveal_with(app, None, RevealSource::Reopen);
591            }
592        }
593        #[cfg(any(target_os = "macos", target_os = "ios"))]
594        RunEvent::Opened { urls } => {
595            let raw: Vec<String> = urls.iter().map(|u| u.to_string()).collect();
596            deliver_links(app, raw.iter().map(String::as_str));
597            crate::windows::reveal_with(app, None, RevealSource::Opened);
598        }
599        _ => {}
600    }
601}
602
603fn shutdown(app: &AppHandle<Wry>) {
604    let state = app.state::<ShellState>();
605    state.exiting.store(true, Ordering::SeqCst);
606    #[cfg(feature = "engine")]
607    if let Some(engine) = &state.engine {
608        engine.shutdown();
609    }
610    if state.tray.is_some() {
611        let _ = app.remove_tray_by_id(crate::tray::TRAY_ID);
612    }
613    // Releasing the claims frees the single-instance record.
614    crate::single_instance::release_all();
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use std::cell::Cell;
621    use std::sync::{Arc, Mutex};
622
623    type Calls = Arc<Mutex<Vec<(Vec<String>, String)>>>;
624
625    #[test]
626    fn default_runs_without_a_callback() {
627        let ran = Cell::new(0);
628        let argv = vec!["app".to_owned()];
629        route_second_instance(&(), None, &argv, "/w", |_, a, c| {
630            assert_eq!((a, c), (&argv[..], "/w"));
631            ran.set(ran.get() + 1);
632        });
633        assert_eq!(ran.get(), 1);
634    }
635
636    #[test]
637    fn custom_callback_gets_argv_and_cwd_and_suppresses_default() {
638        let calls: Calls = Arc::default();
639        let c = calls.clone();
640        let cb: SecondInstanceFn<()> =
641            Arc::new(move |_, a, cwd| c.lock().unwrap().push((a.to_vec(), cwd.to_owned())));
642        let argv = vec!["app".to_owned(), "--x".to_owned()];
643        route_second_instance(&(), Some(&cb), &argv, "/work", |_, _, _| {
644            panic!("default focus behaviour must not run")
645        });
646        assert_eq!(*calls.lock().unwrap(), vec![(argv, "/work".to_owned())]);
647    }
648
649    #[test]
650    fn the_default_second_launch_reveals_unless_the_host_declines() {
651        let argv = vec!["app".to_owned()];
652        assert!(second_instance_reveals::<()>(None, &(), &argv, "/w"));
653        let yes: RevealDecider<()> = Arc::new(|_, _, _| true);
654        let no: RevealDecider<()> = Arc::new(|_, a, cwd| a.len() > 5 && cwd == "/w");
655        assert!(second_instance_reveals(Some(&yes), &(), &argv, "/w"));
656        assert!(!second_instance_reveals(Some(&no), &(), &argv, "/w"));
657    }
658}