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.
34pub 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/// Why the shell is bringing a window up. Every source asks the same guard ([`reveal_mode`]).
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum RevealSource {
45    /// macOS Dock reopen (`RunEvent::Reopen`).
46    Reopen,
47    /// The OS handed the app a URL or file (`RunEvent::Opened`).
48    Opened,
49    /// A second launch forwarded to the primary.
50    SecondInstance,
51    /// The main webview became ready.
52    ShowOnReady,
53    /// Tray menu "Show" or a tray click.
54    Tray,
55    /// The `rk_shell_window` `show` command from the renderer.
56    WindowCommand,
57    /// `windows::open` (create or re-reveal a secondary window).
58    OpenWindow,
59    /// `WindowStateStore::restore_with` visibility options.
60    Restore,
61    /// A host call to `windows::reveal`.
62    Api,
63}
64
65/// What the guard allows for one reveal.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum RevealMode {
68    /// Nothing: no show, no unminimise, no focus, no activation.
69    Forbidden,
70    /// QA background: native order-back only; never unminimise or focus.
71    Background,
72    /// Ordinary production reveal.
73    Foreground,
74}
75
76/// The documented truthy values for a never-show environment variable, compared exactly
77/// (ASCII case-insensitive, no trimming).
78pub const NEVER_SHOW_TRUTHY: [&str; 4] = ["1", "true", "yes", "on"];
79
80/// True when an environment value means "never show": exactly one of [`NEVER_SHOW_TRUTHY`]
81/// (`1`, `true`, `yes`, `on`), ASCII case-insensitive. Anything else (unset, empty, `0`, padded or
82/// unrecognised text) is off.
83pub 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
92/// True when the value is exactly `expected` (case-sensitive, no trimming).
93pub fn env_equals(value: Option<&OsStr>, expected: &str) -> bool {
94    value.is_some_and(|v| v == OsStr::new(expected))
95}
96
97/// The host's say over window visibility, evaluated by [`ShowPolicy::mode`].
98#[derive(Debug, Clone)]
99pub struct ShowPolicy {
100    /// Host-declared: the shell never shows, unminimises or focuses a window, and on macOS the app
101    /// is a Prohibited-activation app.
102    pub never_show: bool,
103    /// Extra environment variables that mean never-show when set to a truthy value (see [`env_means_never_show`]).
104    pub never_show_env: Vec<String>,
105    /// Environment variables that mean never-show only when set to exactly the paired value
106    /// (see [`env_equals`]), for a harness whose variable uses its own token.
107    pub never_show_env_values: Vec<(String, String)>,
108    /// Whether `Reopen` and `Opened` may reveal at all (also needs the module).
109    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    /// True when never-show is declared by the host or by any registered environment variable.
125    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    /// [`ShowPolicy::is_never_show`] against the process environment.
138    pub fn never_show_now(&self) -> bool {
139        self.is_never_show(&|name: &str| std::env::var_os(name))
140    }
141
142    /// The one decision. Never-show beats everything; the reopen opt-out beats QA mode.
143    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    /// [`ShowPolicy::mode`] against the process environment and `RIGHTKIT_QA_BACKGROUND`.
163    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
170/// THE guard. Every path that shows, unminimises or focuses a window (reopen, opened, second
171/// instance, show-on-ready, tray, the window command, `windows::open`, `windows::reveal`, restore)
172/// asks this function and does exactly what the answer allows. Without `ShellState` (a standalone
173/// helper outside the plugin) the default policy applies.
174pub 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
181/// True when the host forbade showing windows (builder flag or registered environment variable).
182pub 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
187/// A Dock reopen with no visible window is the only reopen that asks to reveal.
188pub fn reopen_wants_reveal(has_visible_windows: bool) -> bool {
189    !has_visible_windows
190}
191
192/// macOS: make the app Prohibited (no Dock icon, no menu bar, never frontmost) when never-show
193/// applies. Idempotent; called at plugin setup and again when each webview is ready.
194#[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/// How a launch takes part in single-instance arbitration.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum InstanceMode {
207    /// The host turned single instance off: no claim, no forward.
208    Disabled,
209    /// Claim the primary, or forward to it and exit (the default).
210    Arbitrate,
211    /// A hidden QA launch (`RIGHTKIT_QA_HIDDEN=1`) of an app that opted in with
212    /// `qa_instance_exempt`: it neither claims the primary nor forwards to the running instance, so
213    /// it can never be revealed by, or reveal, the user's own copy.
214    QaExempt,
215}
216
217/// Pure decision for [`InstanceMode`].
218pub 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
226/// Whether the main window is shown when its webview is ready.
227///
228/// `explicit` is what the host passed to `show_on_ready` (`None`: never called). An explicit choice
229/// is the host's visibility decision, so `RIGHTKIT_QA_HIDDEN` only decides when the host said
230/// nothing. A login launch (`--background`) and a disabled module always keep the window hidden.
231pub 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
243/// Whether the `ShowOnReady` behaviour is installed. `lifecycle_only` turns the module off, and
244/// only the host's explicit `show_on_ready(true)` brings it back; every other module set is
245/// decided by the module alone.
246pub 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
254/// Whether the startup cache repair runs: the host's explicit choice, else on, except under
255/// `lifecycle_only` where the host owns its cache.
256pub fn repair_cache_enabled(explicit: Option<bool>, lifecycle_only: bool) -> bool {
257    explicit.unwrap_or(!lifecycle_only)
258}
259
260/// What happened to the main window.
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub enum Visibility {
263    Hidden,
264    Revealed,
265}
266
267/// macOS activation policy, mirrored so the decision is testable on every host.
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum Activation {
270    /// Dock icon and menu bar (a normal app).
271    Regular,
272    /// No Dock icon, no menu bar: a background app.
273    Accessory,
274}
275
276/// The activation policy a hide or reveal asks for, when the host chose "hide to background".
277/// `other_visible`: some other window is still on screen, so the app must stay in the Dock.
278pub 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/// Remembers the policy last applied so only real changes reach the OS.
294#[derive(Debug, Default)]
295pub struct ActivationTracker {
296    accessory: AtomicBool,
297}
298
299impl ActivationTracker {
300    /// The change to apply for `want`, or `None` when it is no change.
301    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
314/// Called after the main window is hidden or revealed: apply the macOS activation policy for a
315/// "hide to background" host, then run the host's visibility callback.
316pub(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    // A never-show host stays Prohibited: hide/reveal never changes the activation policy.
323    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
348/// Begin a quit from a host-side request (tray Quit): drain when the host opted in, else exit now.
349pub(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
364/// Emit `quit-requested` and arm the bound. False when the renderer cannot be reached.
365fn 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
379/// The renderer finished draining (or never will): exit.
380pub(crate) fn finish_quit(app: &AppHandle<Wry>) {
381    let state = app.state::<ShellState>();
382    // `exiting` first: a quit request racing this one must see ExitNow, not start a new drain.
383    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
413/// An app's second-instance callback: (app, argv, cwd).
414pub(crate) type SecondInstanceFn<A> = std::sync::Arc<dyn Fn(&A, &[String], &str) + Send + Sync>;
415
416/// Second-launch routing: URLs reach the open-URL hub first, then the app's callback owns the
417/// rest; without one the `default` (event, deep links, reveal) runs.
418pub(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
432/// A host's say over whether a second launch reveals the main window: (app, argv, cwd) -> reveal.
433pub(crate) type RevealDecider<A> =
434    std::sync::Arc<dyn Fn(&A, &[String], &str) -> bool + Send + Sync>;
435
436/// True when the default second-launch behaviour should reveal and focus the main window.
437pub(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
470/// Feed the idle machine a minimise or restore of the main window (only for a host that asked with
471/// `IdleOptions::start_on_minimize`).
472fn 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            // `code == None`: the last window closed. A tray-resident app keeps running.
535            if code.is_none() && state.close_to_tray && !state.exiting.load(Ordering::SeqCst) {
536                api.prevent_exit();
537                return;
538            }
539            // Opt-in drain: ask the renderer to flush first. `exiting` set by finish/force/bound
540            // makes this return ExitNow, so the exit that follows never prompts twice.
541            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    // Releasing the claims frees the single-instance record.
596    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}