Skip to main content

rightkit_tauri_shell/
lib.rs

1//! rightkit-tauri-shell: the shared Tauri desktop shell for Right Suite apps.
2//!
3//! What every desktop app needs around its own UI: a hardened webview, window state, a native
4//! menu, a tray, single instance, deep links, autostart, a rich clipboard, a hang watchdog and
5//! the window-to-engine seam. Layout is NOT part of the shell (the sidebar/titlebar layout lives
6//! in `@rightkit/react-shell`, optional and replaceable); this crate is the behaviour underneath.
7//!
8//! ```ignore
9//! tauri::Builder::default()
10//!     .plugin(rightkit_tauri_shell::Shell::builder("myapp").app_name("MyApp").tray(spec).build().plugin())
11//!     .invoke_handler(rightkit_tauri_shell::handler![my_app_command])
12//!     .run(tauri::generate_context!())
13//! ```
14//!
15//! See `API.md` for per-app migration.
16
17pub mod autostart;
18pub mod b64;
19pub mod broker;
20pub mod clipboard;
21pub mod commands;
22pub mod deep_link;
23pub mod devtools;
24#[cfg(feature = "engine")]
25pub mod engine;
26pub mod error;
27pub mod frameless;
28pub mod harden;
29pub mod idle;
30pub mod instance;
31mod legacy_hold;
32mod legacy_instance;
33pub mod lifecycle;
34pub mod menu;
35pub mod modules;
36pub mod nav;
37pub mod notify;
38pub mod open_url;
39pub mod opener;
40pub mod presence;
41pub mod quit;
42pub mod recovery;
43pub mod single_instance;
44pub mod site;
45pub mod startup_cache;
46pub mod stream;
47pub mod tray;
48pub mod watchdog;
49pub mod window_state;
50/// The `windows` / `webview2-com` generation selected by `wv2-0_38` / `wv2-0_39`.
51#[cfg(windows)]
52pub(crate) mod winapi {
53    #[cfg(not(any(feature = "wv2-0_38", feature = "wv2-0_39")))]
54    compile_error!("rightkit-tauri-shell on Windows needs `wv2-0_38` (Tauri 2.11, in default features) or `wv2-0_39` (Tauri 2.12); with default-features = false add one, e.g. features = [\"wv2-0_38\"]");
55    #[cfg(all(feature = "wv2-0_38", not(feature = "wv2-0_39")))]
56    pub use ::{webview2_com_038 as webview2_com, windows_061 as windows};
57    #[cfg(feature = "wv2-0_39")]
58    pub use ::{webview2_com_039 as webview2_com, windows_062 as windows};
59}
60pub mod windows;
61
62use std::collections::HashMap;
63use std::path::PathBuf;
64use std::sync::atomic::AtomicBool;
65use std::sync::{Arc, Mutex};
66
67use tauri::menu::MenuItem;
68use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
69use tauri::{AppHandle, Manager, Wry};
70
71pub use autostart::{AutostartMigration, AutostartOptions, LegacyAutostartNames};
72pub use broker::PathBroker;
73pub use clipboard::{ClipboardItem, ClipboardOptions, ClipboardPolicy, ImagePreference};
74pub use devtools::{ContentWebview, DevToolsError, DevToolsGate, DevToolsPolicy, WebviewDecision};
75pub use error::ShellError;
76pub use harden::{Hardening, HardeningScript};
77pub use idle::{
78    IdleErrorCallback, IdleFailure, IdlePhase, IdleTransition, IdleTransitionCallback,
79    IdleTransitionKind,
80};
81pub use menu::{MenuEntry, MenuGroup, MenuSpec};
82pub use modules::{Module, ModuleSet, ShellPlugin, ShellPluginBuilder};
83pub use nav::{NavDecision, NavPolicy};
84pub use opener::{ExternalOpener, SystemOpener};
85pub use presence::{AutoPrefs, PresenceState};
86pub use recovery::{MigrationOutcome, RecoverySpec};
87pub use single_instance::{
88    ClaimFailure, ClaimOptions, ClaimOutcome, InstanceMessage, LegacySingleInstanceNames,
89    SingleInstance, SingleInstanceGuard,
90};
91pub use site::SitePolicy;
92pub use stream::{StreamEvent, StreamEventKind, StreamObserver, StreamPolicy};
93pub use tray::{LeftClick, TraySpec};
94pub use watchdog::{ShellRuntimeStatus, StallReport, WatchdogConfig, WatchdogStatus};
95
96pub const EVENT_MENU: &str = "rightkit-shell://menu";
97pub const EVENT_TRAY: &str = "rightkit-shell://tray";
98pub const EVENT_DEEP_LINK: &str = "rightkit-shell://deep-link";
99pub const EVENT_SECOND_INSTANCE: &str = "rightkit-shell://second-instance";
100pub const EVENT_STALL: &str = "rightkit-shell://stall";
101pub const EVENT_ENGINE: &str = "rightkit-shell://engine";
102pub const EVENT_WINDOW: &str = "rightkit-shell://window";
103pub const EVENT_IDLE: &str = "rightkit-shell://idle";
104pub const EVENT_PRESENCE: &str = "rightkit-shell://presence";
105/// Emitted instead of exiting when the host opted into [`ShellBuilder::quit_drain`].
106pub const EVENT_QUIT_REQUESTED: &str = "rightkit-shell://quit-requested";
107
108type MenuCallback = Arc<dyn Fn(&AppHandle<Wry>, &str) + Send + Sync>;
109type PresenceCallback = Arc<dyn Fn(&AppHandle<Wry>, PresenceState) + Send + Sync>;
110type VisibilityCallback = Arc<dyn Fn(&AppHandle<Wry>, lifecycle::Visibility) + Send + Sync>;
111type RevealDecider = lifecycle::RevealDecider<AppHandle<Wry>>;
112
113/// What closing the main window does.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
115pub enum CloseBehavior {
116    /// Closing the last window quits (the default).
117    #[default]
118    Quit,
119    /// Closing hides the main window; the app keeps running (what `close_to_tray(true)` does).
120    HideToTray,
121    /// Like `HideToTray`, and on macOS the app also drops to an Accessory app (no Dock icon, no menu
122    /// bar) while hidden and returns to Regular when revealed. On other platforms it is `HideToTray`.
123    HideToBackground,
124}
125
126/// Everything the shell shares at runtime (managed Tauri state).
127pub struct ShellState {
128    // Read only by the macOS native-dispatch path.
129    #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
130    pub(crate) main_thread_id: std::thread::ThreadId,
131    pub app_id: String,
132    pub app_name: String,
133    pub main_label: String,
134    pub nav: Arc<NavPolicy>,
135    pub opener: Arc<dyn ExternalOpener>,
136    pub frameless: bool,
137    pub close_to_tray: bool,
138    /// `CloseBehavior::HideToBackground`: hide and reveal also change the macOS activation policy.
139    pub hide_to_background: bool,
140    pub on_visibility: Option<VisibilityCallback>,
141    pub(crate) activation: lifecycle::ActivationTracker,
142    /// The host's window-visibility policy, obeyed by [`lifecycle::reveal_mode`].
143    pub show_policy: lifecycle::ShowPolicy,
144    /// The opt-in quit drain (see [`quit`]).
145    pub quit: Arc<quit::QuitGate>,
146    pub windows: Arc<window_state::WindowStateStore>,
147    pub deep: deep_link::DeepLinkQueue,
148    pub schemes: Vec<String>,
149    pub frontend_ready: AtomicBool,
150    pub exiting: AtomicBool,
151    pub traffic: frameless::TrafficLights,
152    pub autostart: autostart::AutostartSpec,
153    pub menu_items: Mutex<HashMap<String, MenuItem<Wry>>>,
154    pub menu_rows: Mutex<Vec<(String, String)>>,
155    pub tray: Option<tray::TrayRuntime>,
156    pub on_menu: Option<MenuCallback>,
157    #[cfg(feature = "engine")]
158    pub engine: Option<Arc<engine::EngineHost>>,
159    pub hosted: Mutex<Option<rightkit_daemon::Hosted>>,
160    pub single_instance: Mutex<String>,
161    /// Grants for files the person handed to the app (see [`broker`]).
162    pub broker: Arc<PathBroker>,
163    pub notifications: notify::NotificationState,
164    pub presence: Arc<presence::PresenceDnd>,
165    pub idle: Option<idle::IdleHost>,
166    pub recovery: Option<recovery::RecoveryStore>,
167    /// Result of the startup cache-directory repair (`None` when disabled).
168    pub cache_repair: Mutex<Option<(PathBuf, recovery::CacheDirOutcome)>>,
169    /// First scheme registered with [`ShellBuilder::stream_protocol`]; the default for `rk_shell_stream_grant`.
170    pub stream_scheme: Option<String>,
171    /// Every scheme registered with [`ShellBuilder::stream_protocol`].
172    pub stream_schemes: Vec<String>,
173    /// Scheme registered with [`ShellBuilder::site_protocol`].
174    pub site_scheme: Option<String>,
175    pub site_policy: site::SitePolicy,
176    /// Modules this app installed (see [`Module`]).
177    pub modules: ModuleSet,
178    pub clipboard: clipboard::ClipboardPolicy,
179    /// Read-side clipboard options (Windows image preference, opt-in unknown formats).
180    pub clipboard_options: clipboard::ClipboardOptions,
181    /// Per-app temp directory for clipboard images.
182    pub clipboard_dir: PathBuf,
183}
184
185/// Called on the first instance when a later launch forwards its argv and working directory.
186pub type SecondInstanceCallback = Arc<dyn Fn(&AppHandle<Wry>, &[String], &str) + Send + Sync>;
187
188pub struct ShellBuilder {
189    app_id: String,
190    app_name: String,
191    main_label: String,
192    hardening: Hardening,
193    hardening_script: HardeningScript,
194    devtools_policy: DevToolsPolicy,
195    content_webviews: Vec<ContentWebview>,
196    nav: NavPolicy,
197    extra_internal_schemes: Vec<String>,
198    opener: Arc<dyn ExternalOpener>,
199    single_instance: bool,
200    close_to_tray: bool,
201    hide_to_background: bool,
202    on_visibility: Option<VisibilityCallback>,
203    never_show: bool,
204    never_show_env: Vec<String>,
205    never_show_env_values: Vec<(String, String)>,
206    reveal_on_reopen: bool,
207    lifecycle_only: bool,
208    qa_instance_exempt: bool,
209    on_second_instance_reveal: Option<RevealDecider>,
210    quit_drain: Option<std::time::Duration>,
211    frameless: bool,
212    show_on_ready: Option<bool>,
213    state_dir: Option<PathBuf>,
214    schemes: Vec<String>,
215    menu: Option<MenuSpec>,
216    tray: Option<TraySpec>,
217    watchdog: Option<WatchdogConfig>,
218    on_menu: Option<MenuCallback>,
219    on_second_instance: Option<SecondInstanceCallback>,
220    streams: Vec<(String, StreamPolicy)>,
221    site: Option<(String, site::SitePolicy)>,
222    modules: ModuleSet,
223    clipboard: clipboard::ClipboardPolicy,
224    clipboard_options: clipboard::ClipboardOptions,
225    idle_on_error: Option<idle::IdleErrorCallback>,
226    idle_on_transition: Option<idle::IdleTransitionCallback>,
227    autostart: autostart::AutostartOptions,
228    recovery: Option<RecoverySpec>,
229    idle_after: Option<std::time::Duration>,
230    idle_options: Option<idle::IdleOptions>,
231    cache_dir: Option<PathBuf>,
232    repair_cache: Option<bool>,
233    on_presence: Option<PresenceCallback>,
234    #[cfg(feature = "engine")]
235    engine: Option<engine::EngineSpec>,
236}
237
238pub struct Shell {
239    cfg: ShellBuilder,
240}
241
242impl Shell {
243    /// `app_id`: lowercase letters, digits and `-` (it names the single-instance service and the
244    /// LaunchAgent / registry entry), at most 40 characters.
245    pub fn builder(app_id: &str) -> ShellBuilder {
246        ShellBuilder {
247            app_id: app_id.to_owned(),
248            app_name: app_id.to_owned(),
249            main_label: "main".into(),
250            hardening: Hardening::default(),
251            hardening_script: HardeningScript::default(),
252            devtools_policy: DevToolsPolicy::default(),
253            content_webviews: Vec::new(),
254            nav: NavPolicy::default(),
255            extra_internal_schemes: Vec::new(),
256            opener: Arc::new(SystemOpener),
257            single_instance: true,
258            close_to_tray: false,
259            hide_to_background: false,
260            on_visibility: None,
261            never_show: false,
262            never_show_env: Vec::new(),
263            never_show_env_values: Vec::new(),
264            reveal_on_reopen: true,
265            lifecycle_only: false,
266            qa_instance_exempt: false,
267            on_second_instance_reveal: None,
268            quit_drain: None,
269            frameless: false,
270            show_on_ready: None,
271            state_dir: None,
272            schemes: Vec::new(),
273            menu: None,
274            tray: None,
275            watchdog: Some(WatchdogConfig::default()),
276            on_menu: None,
277            on_second_instance: None,
278            streams: Vec::new(),
279            site: None,
280            modules: ModuleSet::all(),
281            clipboard: clipboard::ClipboardPolicy::default(),
282            clipboard_options: clipboard::ClipboardOptions::default(),
283            idle_on_error: None,
284            idle_on_transition: None,
285            autostart: autostart::AutostartOptions::default(),
286            recovery: None,
287            idle_after: None,
288            idle_options: None,
289            cache_dir: None,
290            repair_cache: None,
291            on_presence: None,
292            #[cfg(feature = "engine")]
293            engine: None,
294        }
295    }
296}
297
298impl ShellBuilder {
299    pub fn app_name(mut self, name: &str) -> Self {
300        self.app_name = name.to_owned();
301        self
302    }
303    pub fn main_label(mut self, label: &str) -> Self {
304        self.main_label = label.to_owned();
305        self
306    }
307    pub fn hardening(mut self, h: Hardening) -> Self {
308        self.hardening = h;
309        self
310    }
311    /// Select DOM script injection scope for the full plugin (default: all frames).
312    /// Native hardening & navigation policy still apply when the script is off.
313    pub fn hardening_script(mut self, scope: HardeningScript) -> Self {
314        self.hardening_script = scope;
315        self
316    }
317    /// Who may use DevTools. The default, [`DevToolsPolicy::Denied`], makes no webview of the app
318    /// inspectable. [`DevToolsPolicy::ContentOnly`] is a named allowance for an app that renders
319    /// opened content (ViewRight) and must also register that content with
320    /// [`ShellBuilder::content_webview`]. The policy applies whichever modules are installed.
321    pub fn devtools_policy(mut self, p: DevToolsPolicy) -> Self {
322        self.devtools_policy = p;
323        self
324    }
325    /// Register a webview that renders opened content as inspectable under
326    /// [`DevToolsPolicy::ContentOnly`]. The main webview can never be registered, and setup fails
327    /// with a typed [`DevToolsError`] when the content webview could reach the app's IPC.
328    pub fn content_webview(mut self, c: ContentWebview) -> Self {
329        self.content_webviews.push(c);
330        self
331    }
332    pub fn nav_policy(mut self, p: NavPolicy) -> Self {
333        self.nav = p;
334        self
335    }
336    /// Treat `scheme` as the app's own content: the webview loads it in place. Adds to
337    /// `nav_policy` whatever order they are called in. See [`NavPolicy::with_internal_scheme`].
338    ///
339    /// # Panics
340    /// Panics on a scheme that is empty, malformed, or a web or script scheme (`http`, `https`,
341    /// `file`, `javascript`, ...): a programmer error at startup, like an invalid app id.
342    pub fn internal_scheme(mut self, scheme: &str) -> Self {
343        nav::validate_internal_scheme(scheme).expect("rightkit-tauri-shell: internal_scheme");
344        self.extra_internal_schemes.push(scheme.to_owned());
345        self
346    }
347    /// Replace the system link handler (tests, or an app that routes links itself).
348    pub fn opener(mut self, o: impl ExternalOpener) -> Self {
349        self.opener = Arc::new(o);
350        self
351    }
352    pub fn single_instance(mut self, on: bool) -> Self {
353        self.single_instance = on;
354        self
355    }
356    /// Closing the main window hides it; the app keeps running in the tray until Quit.
357    pub fn close_to_tray(mut self, on: bool) -> Self {
358        self.close_to_tray = on;
359        if !on {
360            self.hide_to_background = false;
361        }
362        self
363    }
364    /// What closing the main window does. The last of `close_to_tray` and `close_behavior` wins.
365    /// [`CloseBehavior::HideToBackground`] is for an app with no tray: it leaves the Dock while
366    /// hidden on macOS and is reachable from wherever the host reveals it (a menu-bar item, a
367    /// notch, a hotkey, a second launch).
368    pub fn close_behavior(mut self, behavior: CloseBehavior) -> Self {
369        self.close_to_tray = behavior != CloseBehavior::Quit;
370        self.hide_to_background = behavior == CloseBehavior::HideToBackground;
371        self
372    }
373    /// Called after the main window is hidden (close, tray toggle) or revealed (tray, second launch,
374    /// Dock reopen, opened URL), on the thread that raised the event. Runs after the shell's own
375    /// activation-policy change, so a host can drive its own policy with `close_to_tray(true)`
376    /// instead of `HideToBackground`.
377    pub fn on_visibility(
378        mut self,
379        f: impl Fn(&AppHandle<Wry>, lifecycle::Visibility) + Send + Sync + 'static,
380    ) -> Self {
381        self.on_visibility = Some(Arc::new(f));
382        self
383    }
384    /// Host-declared "never show": the shell shows, unminimises and focuses no window under any
385    /// event (reopen, opened URL, second launch, show-on-ready, tray, the window command,
386    /// `windows::open`/`reveal`, window-state restore), and on macOS the activation policy is set
387    /// to Prohibited at plugin setup, before any window exists. Absolute: it beats `show_on_ready`,
388    /// `second_instance_reveal` and every other visibility option. Default off. See also
389    /// [`ShellBuilder::never_show_env`]. The host's own direct window calls are outside the shell.
390    pub fn never_show(mut self, on: bool) -> Self {
391        self.never_show = on;
392        self
393    }
394    /// Register an environment variable that means never-show for this process when it is set to
395    /// exactly `1`, `true`, `yes` or `on` (ASCII case-insensitive; anything else, including empty,
396    /// `0` and unrecognised text, is off; read at each reveal, so a harness can set it before
397    /// launch). Call it once per name (ScrapeRight: `RIGHTKIT_QA_NATIVE_REAL`).
398    ///
399    /// # Panics
400    /// Panics on an empty name or one containing `=` or NUL: a programmer error at startup.
401    pub fn never_show_env(mut self, name: &str) -> Self {
402        assert!(
403            !name.is_empty() && !name.contains(['=', '\0']),
404            "rightkit-tauri-shell: never_show_env needs a plain environment variable name"
405        );
406        if !self.never_show_env.iter().any(|n| n == name) {
407            self.never_show_env.push(name.to_owned());
408        }
409        self
410    }
411    /// Register an environment variable that means never-show only when it is set to exactly
412    /// `expected` (case-sensitive, no trimming); any other value, or unset, is off. For a harness
413    /// that uses its own token.
414    ///
415    /// # Panics
416    /// Panics on an empty name (or one containing `=` or NUL) or an empty `expected`.
417    pub fn never_show_env_value(mut self, name: &str, expected: &str) -> Self {
418        assert!(
419            !name.is_empty() && !name.contains(['=', '\0']) && !expected.is_empty(),
420            "rightkit-tauri-shell: never_show_env_value needs a plain variable name and a non-empty expected value"
421        );
422        let pair = (name.to_owned(), expected.to_owned());
423        if !self.never_show_env_values.contains(&pair) {
424            self.never_show_env_values.push(pair);
425        }
426        self
427    }
428    /// Whether a macOS Dock reopen with no visible window, and an OS-delivered URL or file
429    /// (`RunEvent::Opened`), reveal the main window (default on). Off: both still deliver their
430    /// links and nothing is shown, unminimised or focused. Also off when `Module::RevealOnReopen`
431    /// is not installed.
432    pub fn reveal_on_reopen(mut self, on: bool) -> Self {
433        self.reveal_on_reopen = on;
434        self
435    }
436    /// A launch with `RIGHTKIT_QA_HIDDEN=1` neither claims the primary nor forwards to the running
437    /// instance, so a hidden QA copy never reveals the user's app and is never revealed by it.
438    /// Default off (a hidden QA launch arbitrates like any other).
439    pub fn qa_instance_exempt(mut self, on: bool) -> Self {
440        self.qa_instance_exempt = on;
441        self
442    }
443    /// Keep the default second-launch behaviour (the `second-instance` event and deep links) but let
444    /// the host decide whether it reveals and focuses the main window: `f` returns true to reveal.
445    /// Ignored when [`ShellBuilder::on_second_instance`] replaces the default.
446    pub fn second_instance_reveal(
447        mut self,
448        f: impl Fn(&AppHandle<Wry>, &[String], &str) -> bool + Send + Sync + 'static,
449    ) -> Self {
450        self.on_second_instance_reveal = Some(Arc::new(f));
451        self
452    }
453    /// Let the renderer drain before the app exits. A quit request (tray Quit, Cmd+Q, the last
454    /// window closing with a webview still alive) then emits `rightkit-shell://quit-requested`
455    /// instead of exiting, and the app exits when the renderer calls `rk_shell_finish_quit` or after
456    /// [`quit::DEFAULT_QUIT_DRAIN_BOUND`] (15 s), whichever comes first. Off by default: quit is
457    /// immediate. `rk_shell_quit` (and `shell.quit({force: true})`) stays immediate either way.
458    pub fn quit_drain(mut self, on: bool) -> Self {
459        self.quit_drain = on.then_some(quit::DEFAULT_QUIT_DRAIN_BOUND);
460        self
461    }
462    /// [`ShellBuilder::quit_drain`] with the host-side bound set (clamped to
463    /// [`quit::MAX_QUIT_DRAIN_BOUND`], 120 s). Give the renderer's own `quitTimeoutMs` (10 s by
464    /// default) less than this so its timeout reports before the host gives up.
465    pub fn quit_drain_bound(mut self, bound: std::time::Duration) -> Self {
466        self.quit_drain = Some(bound);
467        self
468    }
469    /// Frameless chrome for windows created through `windows::builder`/`open` (see `frameless`).
470    pub fn frameless(mut self, on: bool) -> Self {
471        self.frameless = on;
472        self
473    }
474    /// Show the main window when its webview is ready (default on). Calling this is an explicit
475    /// visibility decision: `RIGHTKIT_QA_HIDDEN` no longer overrides it. `--background` still does.
476    pub fn show_on_ready(mut self, on: bool) -> Self {
477        self.show_on_ready = Some(on);
478        self
479    }
480    /// Directory for window-state.json (default: `<app data dir>/shell`).
481    pub fn state_dir(mut self, dir: PathBuf) -> Self {
482        self.state_dir = Some(dir);
483        self
484    }
485    /// URL schemes this app accepts as deep links (`myapp` for `myapp://...`).
486    pub fn deep_link_scheme(mut self, scheme: &str) -> Self {
487        self.schemes.push(scheme.to_ascii_lowercase());
488        self
489    }
490    pub fn menu(mut self, m: MenuSpec) -> Self {
491        self.menu = Some(m);
492        self
493    }
494    pub fn tray(mut self, t: TraySpec) -> Self {
495        self.tray = Some(t);
496        self
497    }
498    /// Login-item options: the launch arguments, macOS `LowPriorityIO`, and removing the entry
499    /// `tauri-plugin-autostart` wrote (see [`autostart`]).
500    pub fn autostart_options(mut self, options: autostart::AutostartOptions) -> Self {
501        self.autostart = options;
502        self
503    }
504    /// Called once per stall episode with the stall duration, on the watchdog thread (a host can
505    /// write a hang dump here). Applies to the watchdog config in place: call it after
506    /// [`ShellBuilder::watchdog`], or it has no effect. No effect when the watchdog is disabled.
507    pub fn on_stall(mut self, f: impl Fn(&watchdog::StallReport) + Send + Sync + 'static) -> Self {
508        if let Some(config) = self.watchdog.as_mut() {
509            config.on_stall = Some(Arc::new(f));
510        }
511        self
512    }
513    pub fn watchdog(mut self, w: Option<WatchdogConfig>) -> Self {
514        self.watchdog = w;
515        self
516    }
517    /// Rust-side handler for custom menu item clicks (the event is always emitted too).
518    /// Replace the default second-launch behaviour (event + reveal the main window) with `f`,
519    /// which receives the first instance's handle, the second launch's argv and its cwd, and fully
520    /// owns focus. Arbitration is unchanged. URLs on that argv still reach [`open_url`] handlers
521    /// and [`open_url::current_urls`] before `f` runs.
522    pub fn on_second_instance(
523        mut self,
524        f: impl Fn(&AppHandle<Wry>, &[String], &str) + Send + Sync + 'static,
525    ) -> Self {
526        self.on_second_instance = Some(Arc::new(f));
527        self
528    }
529    pub fn on_menu(mut self, f: impl Fn(&AppHandle<Wry>, &str) + Send + Sync + 'static) -> Self {
530        self.on_menu = Some(Arc::new(f));
531        self
532    }
533    /// Serve broker-granted files over a ranged custom protocol: `<scheme>://localhost/<id>` with
534    /// the id from `rk_shell_stream_grant` (see [`stream`]). Off unless this is called.
535    pub fn stream_protocol(mut self, scheme: &str, policy: StreamPolicy) -> Self {
536        let scheme = scheme.to_ascii_lowercase();
537        self.streams.retain(|(s, _)| *s != scheme);
538        self.streams.push((scheme, policy));
539        self
540    }
541    /// Serve allowlisted subresources of granted site roots over `<scheme>://localhost/<token>/<path>`
542    /// (see [`site`]). Granted with the `rk_shell_site_grant` command. Off unless this is called.
543    pub fn site_protocol(mut self, scheme: &str, policy: site::SitePolicy) -> Self {
544        self.site = Some((scheme.to_ascii_lowercase(), policy));
545        self
546    }
547    /// Age after which clipboard temp images are purged (default one hour; see [`clipboard`]).
548    pub fn clipboard_purge_after(mut self, age: std::time::Duration) -> Self {
549        self.clipboard.purge_after = age;
550        self
551    }
552    /// Read-side clipboard options for `rk_shell_clipboard_read` and `rk_shell_clipboard_item`: the
553    /// Windows bitmap preference and the opt-in capture of unknown pasteboard types (see
554    /// [`clipboard::ClipboardOptions`]). The default captures no unknown types and prefers PNG.
555    pub fn clipboard_options(mut self, options: clipboard::ClipboardOptions) -> Self {
556        self.clipboard_options = options;
557        self
558    }
559    /// Enable crash-safe setup recovery commands (see [`recovery`]).
560    pub fn recovery(mut self, spec: RecoverySpec) -> Self {
561        self.recovery = Some(spec);
562        self
563    }
564    /// Suspend the main webview after it has been hidden this long (see [`idle`]). Off by default.
565    pub fn webview_idle(mut self, suspend_after: std::time::Duration) -> Self {
566        self.idle_after = Some(suspend_after);
567        self
568    }
569    /// [`ShellBuilder::webview_idle`] with the full option set: the per-host timeout
570    /// (`IdleOptions::suspend_after`, default [`idle::DEFAULT_SUSPEND_AFTER`]), starting the timer
571    /// when the main window is minimised, hiding the WebView2 controller when the timer fires
572    /// instead of at once, and whether idle events are emitted where nothing is suspended. A later
573    /// `webview_idle(d)` replaces only the timeout.
574    pub fn webview_idle_options(mut self, options: idle::IdleOptions) -> Self {
575        self.idle_after = Some(options.suspend_after);
576        self.idle_options = Some(options);
577        self
578    }
579    /// Receive suspend and resume failures of the main webview (see [`idle::IdleFailure`]). Without
580    /// this, platform failures are silent.
581    pub fn webview_idle_on_error(
582        mut self,
583        f: impl Fn(&idle::IdleFailure) + Send + Sync + 'static,
584    ) -> Self {
585        self.idle_on_error = Some(Arc::new(f));
586        self
587    }
588    /// Receive one [`idle::IdleTransition`] each time the main webview is really suspended or
589    /// resumed (window label and elapsed time), for the host's log. Without this nothing changes.
590    pub fn webview_idle_on_transition(
591        mut self,
592        f: impl Fn(&idle::IdleTransition) + Send + Sync + 'static,
593    ) -> Self {
594        self.idle_on_transition = Some(Arc::new(f));
595        self
596    }
597    /// App cache directory to keep usable (default: Tauri's app cache dir).
598    pub fn cache_dir(mut self, dir: PathBuf) -> Self {
599        self.cache_dir = Some(dir);
600        self
601    }
602    /// Repair a dangling symlink / stray file at the cache directory on startup (default on; default
603    /// off under `lifecycle_only`, where this call turns it on).
604    pub fn repair_cache(mut self, on: bool) -> Self {
605        self.repair_cache = Some(on);
606        self
607    }
608    /// Rust-side hook for automatic presence changes (meeting started, fullscreen app, ...).
609    pub fn on_presence(
610        mut self,
611        f: impl Fn(&AppHandle<Wry>, PresenceState) + Send + Sync + 'static,
612    ) -> Self {
613        self.on_presence = Some(Arc::new(f));
614        self
615    }
616    #[cfg(feature = "engine")]
617    pub fn engine(mut self, spec: engine::EngineSpec) -> Self {
618        self.engine = Some(spec);
619        self
620    }
621    pub fn build(self) -> Shell {
622        assert!(
623            !self.app_id.is_empty()
624                && self.app_id.len() <= 40
625                && self
626                    .app_id
627                    .chars()
628                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
629            "rightkit-tauri-shell: app_id must be lowercase letters, digits and '-' (<= 40 chars)"
630        );
631        Shell { cfg: self }
632    }
633}
634
635impl Shell {
636    /// Install single-instance arbitration only. Add this plugin FIRST.
637    /// Only the builder's app_id is used; arbitration is always enabled.
638    /// The callback receives forwarded argv & cwd and owns all second-launch behavior.
639    /// No script, navigation policy, window state, presence loop or ShellState is installed.
640    /// Keep using the app's own invoke handler; `handler!` requires the full shell.
641    pub fn single_instance_plugin(
642        self,
643        on_second_instance: impl Fn(&AppHandle<Wry>, &[String], &str) + Send + Sync + 'static,
644    ) -> TauriPlugin<Wry> {
645        let app_id = self.cfg.app_id;
646        PluginBuilder::<Wry>::new("rightkit-single-instance")
647            .setup(move |app, _api| {
648                let handle = app.clone();
649                let version = app.package_info().version.to_string();
650                let identifier = app.config().identifier.clone();
651                let arbitration = instance::arbitrate_with_legacy(
652                    &app_id,
653                    &version,
654                    Some(identifier.as_str()),
655                    Arc::new(move |second| {
656                        on_second_instance(&handle, &second.argv, &second.cwd);
657                    }),
658                );
659                match arbitration {
660                    instance::Arbitration::Primary(hosted) => {
661                        app.manage(PluginClaim(single_instance::hold(hosted)));
662                    }
663                    instance::Arbitration::Secondary => std::process::exit(0),
664                    // Fail closed: an undecidable claim must not run as a second primary.
665                    instance::Arbitration::Unavailable(why) => {
666                        return Err(unavailable_error(&why));
667                    }
668                    instance::Arbitration::InvalidAppId(e) => return Err(invalid_app_id_error(&e)),
669                }
670                Ok(())
671            })
672            .on_event(|app, event| {
673                if matches!(event, tauri::RunEvent::Exit) {
674                    if let Some(claim) = app.try_state::<PluginClaim>() {
675                        single_instance::release(claim.0);
676                    }
677                }
678            })
679            .build()
680    }
681
682    /// The shell's lifecycle without its webview policy: single instance, close behaviour, tray,
683    /// menu, second-launch routing, deep links, quit and the commands, but no hardening init script
684    /// or native hardening, no navigation policy, no presence thread, no watchdog and no
685    /// window-state restore or save. The same as
686    /// `ShellPlugin::builder().shell(self).lifecycle_only()`. [`Shell::plugin`] is unchanged.
687    pub fn lifecycle_plugin(self) -> TauriPlugin<Wry> {
688        ShellPlugin::builder().shell(self).lifecycle_only()
689    }
690
691    /// The single Tauri plugin that installs the whole shell, with every module enabled. Add it FIRST
692    /// so single-instance arbitration runs before any other plugin does work. To opt modules out, use
693    /// [`ShellPlugin::builder`].
694    pub fn plugin(self) -> TauriPlugin<Wry> {
695        ShellPlugin::builder().shell(self).build()
696    }
697}
698
699/// Build the plugin for `cfg`, honouring `cfg.modules`. Shared by [`Shell::plugin`] and
700/// [`ShellPluginBuilder::build`].
701pub(crate) fn install(cfg: ShellBuilder) -> TauriPlugin<Wry> {
702    let modules = cfg.modules;
703    let mut nav_policy = cfg.nav.clone();
704    for scheme in &cfg.extra_internal_schemes {
705        nav_policy = nav_policy
706            .with_internal_scheme(scheme)
707            .expect("rightkit-tauri-shell: internal_scheme");
708    }
709    let nav = Arc::new(nav_policy);
710    let opener = Arc::clone(&cfg.opener);
711    let hardening = cfg.hardening.clone();
712    let script_scope = cfg.hardening_script;
713    let (nav_hook, opener_hook) = (Arc::clone(&nav), Arc::clone(&opener));
714    let hardening_for_views = hardening.clone();
715    // DevTools: the process guard and the gate. A failure is carried to plugin setup, which
716    // refuses to start; until then every webview is treated as denied.
717    let guard_result = devtools::guard_process_start();
718    let gate_result = devtools::build_gate_with(
719        cfg.devtools_policy,
720        &cfg.main_label,
721        &cfg.content_webviews,
722        cfg!(feature = "content-devtools"),
723        devtools::DEV_INSPECT_COMPILED,
724    );
725    let gate_for_views = gate_result
726        .clone()
727        .unwrap_or_else(|_| DevToolsGate::denied(&cfg.main_label));
728    let content_labels = gate_for_views.content_labels();
729    let harden_webviews = modules.contains(Module::JsHardening);
730    let show_on_ready_explicit = cfg.show_on_ready;
731    // `lifecycle_only` turns ShowOnReady off; only the host's explicit `show_on_ready(true)` brings
732    // it back. Every other module set is decided by `modules` alone.
733    let show_on_ready_module = lifecycle::show_on_ready_installed(
734        modules.contains(Module::ShowOnReady),
735        cfg.lifecycle_only,
736        show_on_ready_explicit,
737    );
738    let ready_policy = show_policy_of(&cfg);
739    let restore_windows = modules.contains(Module::WindowState);
740    let main_label = cfg.main_label.clone();
741    let (streams, site) = if modules.contains(Module::Stream) {
742        (cfg.streams.clone(), cfg.site.clone())
743    } else {
744        (Vec::new(), None)
745    };
746
747    let mut plugin = PluginBuilder::<Wry>::new("rightkit-shell");
748    // The browser process may write its port file after the webview is ready: look once more
749    // when the first page has loaded.
750    plugin = plugin.on_page_load(|webview, payload| {
751        if matches!(payload.event(), tauri::webview::PageLoadEvent::Finished) {
752            if let Err(e) = devtools::verify_after_first_navigation(webview.app_handle()) {
753                eprintln!("{e}");
754                webview.app_handle().exit(1);
755            }
756        }
757    });
758    if modules.contains(Module::NavigationPolicy) {
759        plugin = plugin.on_navigation(move |_webview, url| match nav_hook.decide(url) {
760            NavDecision::Allow => true,
761            NavDecision::External { url } => {
762                let _ = opener::open_checked(opener_hook.as_ref(), &url);
763                false
764            }
765            NavDecision::Block => false,
766        });
767    }
768    plugin = plugin
769        .on_webview_ready(move |webview| {
770            let decision = gate_for_views.decide(webview.label());
771            // The DevTools policy is not a module: it applies to every webview, always.
772            // A denied webview whose DevTools could not be disabled is refused: it has been
773            // closed and reported, and nothing else is set up for it.
774            if devtools::apply_native(&webview, decision).is_err() {
775                return;
776            }
777            if harden_webviews {
778                harden::apply_native(&webview, &hardening_for_views, decision);
779            }
780            if let Err(e) = devtools::verify_not_remote_debuggable(webview.app_handle()) {
781                eprintln!("{e}");
782                webview.app_handle().exit(1);
783                return;
784            }
785            let app = webview.app_handle().clone();
786            let label = webview.label().to_owned();
787            lifecycle::enforce_never_show(&app, &ready_policy);
788            windows::with_placement_announced(&app, &label, || {
789                if restore_windows {
790                    if let Some(state) = app.try_state::<ShellState>() {
791                        let _ = state.windows.restore(&app, &label);
792                    }
793                }
794            });
795            if label == main_label
796                && lifecycle::show_on_ready_decision(
797                    show_on_ready_explicit,
798                    show_on_ready_module,
799                    lifecycle::launched_in_background(),
800                    lifecycle::qa_hidden(),
801                )
802            {
803                if let Some(w) = app.get_webview_window(&label) {
804                    // Through the guard: a never-show host gets nothing here.
805                    let _ = crate::windows::show_window(
806                        &app,
807                        &w,
808                        lifecycle::RevealSource::ShowOnReady,
809                        windows::RevealStyle::ShowOnly,
810                    );
811                }
812            }
813        })
814        .on_event(|app, event| {
815            if app.try_state::<ShellState>().is_some() {
816                lifecycle::run_event(app, event);
817            }
818        })
819        .setup(move |app, _api| {
820            let app = app.clone();
821            if let Err(e) = guard_result {
822                eprintln!("{e}");
823                return Err(Box::new(e));
824            }
825            match gate_result {
826                Ok(gate) => {
827                    app.manage(gate);
828                }
829                Err(e) => {
830                    eprintln!("{e}");
831                    return Err(Box::new(e));
832                }
833            }
834            setup(&app, cfg, nav, opener)
835        });
836    if harden_webviews {
837        plugin = match script_scope {
838            HardeningScript::AllFrames => {
839                plugin.js_init_script_on_all_frames(hardening.script_for(&content_labels))
840            }
841            // WebView2 injects even main-frame-only scripts into child frames. Guard the body
842            // as well as requesting native main-frame scope (WKWebView honors that scope).
843            HardeningScript::TopFrameOnly => plugin.js_init_script(format!(
844                "if (window.top !== window.self) {{ return; }}\n{}",
845                hardening.script_for(&content_labels)
846            )),
847            HardeningScript::Off => plugin,
848        };
849    }
850    for (scheme, policy) in streams {
851        plugin = plugin.register_asynchronous_uri_scheme_protocol(
852            scheme,
853            move |ctx, request, responder| {
854                let state = ctx.app_handle().state::<ShellState>();
855                stream::serve(&state.broker, &policy, &request, move |response| {
856                    responder.respond(response)
857                });
858            },
859        );
860    }
861    if let Some((scheme, policy)) = site {
862        plugin = plugin.register_asynchronous_uri_scheme_protocol(
863            scheme,
864            move |ctx, request, responder| {
865                let state = ctx.app_handle().state::<ShellState>();
866                site::serve_site_request(&state.broker, &policy, &request, move |response| {
867                    responder.respond(response)
868                });
869            },
870        );
871    }
872    plugin.build()
873}
874
875/// The claim token of the primary the single-instance plugin holds for the process lifetime.
876struct PluginClaim(u64);
877
878/// The startup error for an undecidable single-instance claim.
879fn unavailable_error(why: &str) -> Box<dyn std::error::Error> {
880    format!("rightkit-tauri-shell: single-instance could not be decided, refusing to start as a second primary: {why}").into()
881}
882
883/// An app id that cannot name the single-instance service: a programming error, so startup fails
884/// with the id and the rule instead of running with single-instance protection silently off.
885fn invalid_app_id_error(e: &instance::InvalidAppId) -> Box<dyn std::error::Error> {
886    format!("rightkit-tauri-shell: single-instance cannot start, app id is not usable: {e}").into()
887}
888
889/// The window-visibility policy a configuration declares.
890fn show_policy_of(cfg: &ShellBuilder) -> lifecycle::ShowPolicy {
891    lifecycle::ShowPolicy {
892        never_show: cfg.never_show,
893        never_show_env: cfg.never_show_env.clone(),
894        never_show_env_values: cfg.never_show_env_values.clone(),
895        reveal_on_reopen: cfg.reveal_on_reopen && cfg.modules.contains(Module::RevealOnReopen),
896    }
897}
898
899fn setup(
900    app: &AppHandle<Wry>,
901    cfg: ShellBuilder,
902    nav: Arc<NavPolicy>,
903    opener: Arc<dyn ExternalOpener>,
904) -> Result<(), Box<dyn std::error::Error>> {
905    // 0. Never-show: Prohibited before arbitration, menus, tray or any window exists.
906    let show_policy = show_policy_of(&cfg);
907    lifecycle::enforce_never_show(app, &show_policy);
908    // 1. Single-instance arbitration, before anything heavy.
909    let mut single = String::from("disabled");
910    let mode = lifecycle::instance_mode(
911        cfg.single_instance,
912        cfg.qa_instance_exempt,
913        lifecycle::qa_hidden(),
914    );
915    if mode == lifecycle::InstanceMode::QaExempt {
916        single = "qa-exempt".into();
917    }
918    if mode == lifecycle::InstanceMode::Arbitrate {
919        let for_cb = app.clone();
920        let custom = cfg.on_second_instance.clone();
921        let decider = cfg.on_second_instance_reveal.clone();
922        let version = app.package_info().version.to_string();
923        let identifier = app.config().identifier.clone();
924        match instance::arbitrate_with_legacy(
925            &cfg.app_id,
926            &version,
927            Some(identifier.as_str()),
928            Arc::new(move |s| {
929                lifecycle::route_second_instance(
930                    &for_cb,
931                    custom.as_ref(),
932                    &s.argv,
933                    &s.cwd,
934                    |app, argv, cwd| {
935                        lifecycle::on_second_instance(app, argv, cwd, decider.as_ref())
936                    },
937                )
938            }),
939        ) {
940            instance::Arbitration::Primary(h) => {
941                // Held for the process lifetime (or until `SingleInstance::destroy`).
942                let _claim = single_instance::hold(h);
943                single = "primary".into();
944            }
945            instance::Arbitration::Secondary => std::process::exit(0),
946            // Fail closed: setup errors abort startup instead of running as a second primary.
947            instance::Arbitration::Unavailable(why) => return Err(unavailable_error(&why)),
948            instance::Arbitration::InvalidAppId(e) => return Err(invalid_app_id_error(&e)),
949        }
950    }
951
952    // 2. Shared state.
953    let state_dir = match cfg.state_dir.clone() {
954        Some(d) => d,
955        None => app
956            .path()
957            .app_data_dir()
958            .map(|d| d.join("shell"))
959            .unwrap_or_else(|_| std::env::temp_dir().join(&cfg.app_id)),
960    };
961    let exe = std::env::current_exe().unwrap_or_default();
962    let tray_runtime = cfg.tray.as_ref().map(|t| tray::TrayRuntime {
963        icon: Mutex::new(None),
964        ids: Mutex::new(Default::default()),
965        show_label: t.show_label.clone(),
966        quit_label: t.quit_label.clone(),
967    });
968    #[cfg(feature = "engine")]
969    let engine = cfg
970        .engine
971        .clone()
972        .map(|s| Arc::new(engine::EngineHost::new(s)));
973    // A lifecycle-only host owns its cache: repair is off unless it asked for it.
974    let cache_repair = if lifecycle::repair_cache_enabled(cfg.repair_cache, cfg.lifecycle_only) {
975        let dir = cfg
976            .cache_dir
977            .clone()
978            .or_else(|| app.path().app_cache_dir().ok());
979        dir.and_then(|d| recovery::ensure_cache_dir(&d).ok().map(|o| (d, o)))
980    } else {
981        None
982    };
983    let presence = Arc::new(presence::PresenceDnd::new());
984    if cfg.modules.contains(Module::Presence) {
985        let (handle, on_presence) = (app.clone(), cfg.on_presence.clone());
986        presence::start_loop(Arc::clone(&presence), cfg.app_name.clone(), move |s| {
987            use tauri::Emitter;
988            let _ = handle.emit(EVENT_PRESENCE, s);
989            if let Some(cb) = &on_presence {
990                cb(&handle, s);
991            }
992        });
993    }
994    let recovery = if cfg.modules.contains(Module::Recovery) {
995        cfg.recovery
996            .clone()
997            .map(|spec| recovery::RecoveryStore::new(spec, &state_dir))
998    } else {
999        None
1000    };
1001    let stream_schemes: Vec<String> = if cfg.modules.contains(Module::Stream) {
1002        cfg.streams.iter().map(|(s, _)| s.clone()).collect()
1003    } else {
1004        Vec::new()
1005    };
1006    let site_scheme = if cfg.modules.contains(Module::Stream) {
1007        cfg.site.as_ref().map(|(s, _)| s.clone())
1008    } else {
1009        None
1010    };
1011    let autostart_spec = autostart::AutostartSpec {
1012        app_id: cfg.app_id.clone(),
1013        app_name: cfg.app_name.clone(),
1014        executable: exe,
1015        options: cfg.autostart.clone(),
1016        legacy_plugin_name: cfg
1017            .autostart
1018            .migrate_from_plugin_entry
1019            .then(|| app.package_info().name.clone()),
1020    };
1021    if cfg.autostart.migrate_from_plugin_entry {
1022        let legacy = app.package_info().name.clone();
1023        match autostart::migrate_plugin_entry(&autostart_spec, &legacy) {
1024            Ok(_) => {}
1025            // A failed migration must not stop startup: the old entry stays until the next launch.
1026            Err(e) => eprintln!(
1027                "rightkit-tauri-shell: could not migrate the tauri-plugin-autostart login entry: {e}"
1028            ),
1029        }
1030    }
1031    app.manage(ShellState {
1032        main_thread_id: std::thread::current().id(),
1033        app_id: cfg.app_id.clone(),
1034        app_name: cfg.app_name.clone(),
1035        main_label: cfg.main_label.clone(),
1036        nav,
1037        opener,
1038        frameless: cfg.frameless && cfg.modules.contains(Module::Frameless),
1039        close_to_tray: cfg.close_to_tray,
1040        hide_to_background: cfg.hide_to_background,
1041        on_visibility: cfg.on_visibility.clone(),
1042        activation: Default::default(),
1043        show_policy,
1044        quit: Arc::new(quit::QuitGate::new(cfg.quit_drain)),
1045        windows: Arc::new(window_state::WindowStateStore::new(state_dir)),
1046        deep: Default::default(),
1047        schemes: cfg.schemes.clone(),
1048        frontend_ready: AtomicBool::new(false),
1049        exiting: AtomicBool::new(false),
1050        traffic: Default::default(),
1051        autostart: autostart_spec,
1052        menu_items: Mutex::new(HashMap::new()),
1053        menu_rows: Mutex::new(Vec::new()),
1054        tray: tray_runtime,
1055        on_menu: cfg.on_menu.clone(),
1056        #[cfg(feature = "engine")]
1057        engine: engine.clone(),
1058        hosted: Mutex::new(None),
1059        single_instance: Mutex::new(single),
1060        broker: Arc::new(PathBroker::new()),
1061        notifications: notify::NotificationState::new(),
1062        presence,
1063        idle: cfg.idle_after.map(|d| {
1064            // `webview_idle(d)` replaces only the timeout; a provider set on the options is kept.
1065            let mut options = cfg.idle_options.unwrap_or_default();
1066            options.suspend_after = d;
1067            idle::IdleHost::new(d)
1068                .with_options(options)
1069                .with_on_error(cfg.idle_on_error.clone())
1070                .with_on_transition(cfg.idle_on_transition.clone())
1071        }),
1072        recovery,
1073        cache_repair: Mutex::new(cache_repair),
1074        stream_scheme: stream_schemes.first().cloned(),
1075        stream_schemes,
1076        site_scheme,
1077        site_policy: cfg
1078            .site
1079            .as_ref()
1080            .map(|(_, p)| p.clone())
1081            .unwrap_or_default(),
1082        modules: cfg.modules,
1083        clipboard: cfg.clipboard,
1084        clipboard_options: cfg.clipboard_options,
1085        clipboard_dir: clipboard::clipboard_temp_dir(&cfg.app_id),
1086    });
1087
1088    // 3. Native menu.
1089    if let Some(spec) = &cfg.menu {
1090        let built = menu::build(
1091            app,
1092            &MenuSpec {
1093                app_name: cfg.app_name.clone(),
1094                ..spec.clone()
1095            },
1096        )?;
1097        let state = app.state::<ShellState>();
1098        *state.menu_rows.lock().unwrap_or_else(|e| e.into_inner()) = menu::describe(&built.menu);
1099        *state.menu_items.lock().unwrap_or_else(|e| e.into_inner()) = built.items;
1100        app.set_menu(built.menu)?;
1101        app.on_menu_event(|app, event| lifecycle::on_menu_event(app, event.id().as_ref()));
1102    }
1103
1104    // 4. Tray.
1105    if let Some(spec) = &cfg.tray {
1106        let icon = tray::install(app, spec, &cfg.main_label)?;
1107        let state = app.state::<ShellState>();
1108        if let Some(rt) = &state.tray {
1109            *rt.icon.lock().unwrap_or_else(|e| e.into_inner()) = Some(icon);
1110        }
1111    }
1112
1113    // 5. Engine, off the UI thread.
1114    #[cfg(feature = "engine")]
1115    if let Some(engine) = engine {
1116        let handle = app.clone();
1117        std::thread::Builder::new()
1118            .name("rk-shell-engine".into())
1119            .spawn(move || {
1120                use tauri::Emitter;
1121                let payload = match engine.start() {
1122                    Ok(hello) => serde_json::json!({"state": "ready", "hello": hello}),
1123                    Err(e) => {
1124                        serde_json::json!({"state": "error", "code": e.code, "message": e.message})
1125                    }
1126                };
1127                engine.set_lifecycle(payload.clone());
1128                let _ = handle.emit(
1129                    EVENT_ENGINE,
1130                    serde_json::json!({"call": "$lifecycle", "event": payload}),
1131                );
1132            })?;
1133    }
1134
1135    // 6. Watchdog.
1136    if cfg.modules.contains(Module::Watchdog) {
1137        if let Some(w) = cfg.watchdog.clone() {
1138            watchdog::install(app.clone(), w);
1139        }
1140    }
1141
1142    // 8 (queue only). Links on the launch argv wait for the frontend.
1143    let argv: Vec<String> = std::env::args().skip(1).collect();
1144    let state = app.state::<ShellState>();
1145    state
1146        .deep
1147        .push_all(argv.iter().map(String::as_str), &state.schemes);
1148    notify::set_default_app_id(&app.config().identifier);
1149    open_url::deliver(&argv);
1150    Ok(())
1151}
1152
1153/// Build the `invoke_handler` with the shell commands plus the app's own.
1154/// `rightkit_tauri_shell::handler![my_command, other]`.
1155#[macro_export]
1156macro_rules! handler {
1157    ($($app_cmd:path),* $(,)?) => {
1158        ::tauri::generate_handler![
1159            $crate::commands::rk_shell_open_external,
1160            $crate::commands::rk_shell_ready,
1161            $crate::commands::rk_shell_status,
1162            $crate::commands::rk_shell_window,
1163            $crate::commands::rk_shell_open_window,
1164            $crate::commands::rk_shell_set_tray,
1165            $crate::commands::rk_shell_set_menu_item_enabled,
1166            $crate::commands::rk_shell_autostart,
1167            $crate::commands::rk_shell_clipboard_read,
1168            $crate::commands::rk_shell_clipboard_write,
1169            $crate::commands::rk_shell_engine_call,
1170            $crate::commands::rk_shell_traffic_lights,
1171            $crate::commands::rk_shell_quit,
1172            $crate::commands::rk_shell_finish_quit,
1173            $crate::commands::rk_shell_capabilities,
1174            $crate::commands::rk_shell_set_theme,
1175            $crate::commands::rk_shell_notify,
1176            $crate::commands::rk_shell_notify_status,
1177            $crate::commands::rk_shell_notify_clear,
1178            $crate::commands::rk_shell_badge,
1179            $crate::commands::rk_shell_set_dnd,
1180            $crate::commands::rk_shell_presence,
1181            $crate::commands::rk_shell_presence_prefs,
1182            $crate::commands::rk_shell_presence_probe,
1183            $crate::commands::rk_shell_presence_evaluate,
1184            $crate::commands::rk_shell_idle_status,
1185            $crate::commands::rk_shell_idle_note,
1186            $crate::commands::rk_shell_path_require,
1187            $crate::commands::rk_shell_path_revoke,
1188            $crate::commands::rk_shell_path_grant_recent,
1189            $crate::commands::rk_shell_path_regrant_saved,
1190            $crate::commands::rk_shell_stream_grant,
1191            $crate::commands::rk_shell_recovery_save,
1192            $crate::commands::rk_shell_recovery_load,
1193            $crate::commands::rk_shell_recovery_clear,
1194            $crate::commands::rk_shell_clipboard_item,
1195            $crate::commands::rk_shell_site_grant,
1196            $crate::commands::rk_shell_site_revoke
1197            $(, $app_cmd)*
1198        ]
1199    };
1200}