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