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    #[cfg(feature = "engine")]
625    pub fn engine(mut self, spec: engine::EngineSpec) -> Self {
626        self.engine = Some(spec);
627        self.engine_policy = Default::default();
628        self
629    }
630    /// Engine wiring with native method/deadline policy & optional bounded supervision.
631    #[cfg(feature = "engine")]
632    pub fn engine_with_policy(
633        mut self,
634        spec: engine::EngineSpec,
635        policy: engine::EnginePolicy,
636    ) -> Self {
637        self.engine = Some(spec);
638        self.engine_policy = policy;
639        self
640    }
641    pub fn build(self) -> Shell {
642        assert!(
643            !self.app_id.is_empty()
644                && self.app_id.len() <= 40
645                && self
646                    .app_id
647                    .chars()
648                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
649            "rightkit-tauri-shell: app_id must be lowercase letters, digits and '-' (<= 40 chars)"
650        );
651        Shell { cfg: self }
652    }
653}
654
655impl Shell {
656    /// Install single-instance arbitration only. Add this plugin FIRST.
657    /// Only the builder's app_id is used; arbitration is always enabled.
658    /// The callback receives forwarded argv & cwd and owns all second-launch behavior.
659    /// No script, navigation policy, window state, presence loop or ShellState is installed.
660    /// Keep using the app's own invoke handler; `handler!` requires the full shell.
661    pub fn single_instance_plugin(
662        self,
663        on_second_instance: impl Fn(&AppHandle<Wry>, &[String], &str) + Send + Sync + 'static,
664    ) -> TauriPlugin<Wry> {
665        let app_id = self.cfg.app_id;
666        PluginBuilder::<Wry>::new("rightkit-single-instance")
667            .setup(move |app, _api| {
668                let handle = app.clone();
669                let version = app.package_info().version.to_string();
670                let identifier = app.config().identifier.clone();
671                let arbitration = instance::arbitrate_with_legacy(
672                    &app_id,
673                    &version,
674                    Some(identifier.as_str()),
675                    Arc::new(move |second| {
676                        on_second_instance(&handle, &second.argv, &second.cwd);
677                    }),
678                );
679                match arbitration {
680                    instance::Arbitration::Primary(hosted) => {
681                        app.manage(PluginClaim(single_instance::hold(hosted)));
682                    }
683                    instance::Arbitration::Secondary => std::process::exit(0),
684                    // Fail closed: an undecidable claim must not run as a second primary.
685                    instance::Arbitration::Unavailable(why) => {
686                        return Err(unavailable_error(&why));
687                    }
688                    instance::Arbitration::InvalidAppId(e) => return Err(invalid_app_id_error(&e)),
689                }
690                Ok(())
691            })
692            .on_event(|app, event| {
693                if matches!(event, tauri::RunEvent::Exit) {
694                    if let Some(claim) = app.try_state::<PluginClaim>() {
695                        single_instance::release(claim.0);
696                    }
697                }
698            })
699            .build()
700    }
701
702    /// The shell's lifecycle without its webview policy: single instance, close behaviour, tray,
703    /// menu, second-launch routing, deep links, quit and the commands, but no hardening init script
704    /// or native hardening, no navigation policy, no presence thread, no watchdog and no
705    /// window-state restore or save. The same as
706    /// `ShellPlugin::builder().shell(self).lifecycle_only()`. [`Shell::plugin`] is unchanged.
707    pub fn lifecycle_plugin(self) -> TauriPlugin<Wry> {
708        ShellPlugin::builder().shell(self).lifecycle_only()
709    }
710
711    /// The single Tauri plugin that installs the whole shell, with every module enabled. Add it FIRST
712    /// so single-instance arbitration runs before any other plugin does work. To opt modules out, use
713    /// [`ShellPlugin::builder`].
714    pub fn plugin(self) -> TauriPlugin<Wry> {
715        ShellPlugin::builder().shell(self).build()
716    }
717}
718
719/// Build the plugin for `cfg`, honouring `cfg.modules`. Shared by [`Shell::plugin`] and
720/// [`ShellPluginBuilder::build`].
721pub(crate) fn install(cfg: ShellBuilder) -> TauriPlugin<Wry> {
722    let modules = cfg.modules;
723    let mut nav_policy = cfg.nav.clone();
724    for scheme in &cfg.extra_internal_schemes {
725        nav_policy = nav_policy
726            .with_internal_scheme(scheme)
727            .expect("rightkit-tauri-shell: internal_scheme");
728    }
729    let nav = Arc::new(nav_policy);
730    let opener = Arc::clone(&cfg.opener);
731    let hardening = cfg.hardening.clone();
732    let script_scope = cfg.hardening_script;
733    let (nav_hook, opener_hook) = (Arc::clone(&nav), Arc::clone(&opener));
734    let hardening_for_views = hardening.clone();
735    // DevTools: the process guard and the gate. A failure is carried to plugin setup, which
736    // refuses to start; until then every webview is treated as denied.
737    let guard_result = devtools::guard_process_start();
738    let gate_result = devtools::build_gate_with(
739        cfg.devtools_policy,
740        &cfg.main_label,
741        &cfg.content_webviews,
742        cfg!(feature = "content-devtools"),
743        devtools::DEV_INSPECT_COMPILED,
744    );
745    let gate_for_views = gate_result
746        .clone()
747        .unwrap_or_else(|_| DevToolsGate::denied(&cfg.main_label));
748    let content_labels = gate_for_views.content_labels();
749    let harden_webviews = modules.contains(Module::JsHardening);
750    let show_on_ready_explicit = cfg.show_on_ready;
751    // `lifecycle_only` turns ShowOnReady off; only the host's explicit `show_on_ready(true)` brings
752    // it back. Every other module set is decided by `modules` alone.
753    let show_on_ready_module = lifecycle::show_on_ready_installed(
754        modules.contains(Module::ShowOnReady),
755        cfg.lifecycle_only,
756        show_on_ready_explicit,
757    );
758    let ready_policy = show_policy_of(&cfg);
759    let restore_windows = modules.contains(Module::WindowState);
760    let main_label = cfg.main_label.clone();
761    let (streams, site) = if modules.contains(Module::Stream) {
762        (cfg.streams.clone(), cfg.site.clone())
763    } else {
764        (Vec::new(), None)
765    };
766
767    let mut plugin = PluginBuilder::<Wry>::new("rightkit-shell");
768    // The browser process may write its port file after the webview is ready: look once more
769    // when the first page has loaded.
770    plugin = plugin.on_page_load(|webview, payload| {
771        if matches!(payload.event(), tauri::webview::PageLoadEvent::Finished) {
772            if let Err(e) = devtools::verify_after_first_navigation(webview.app_handle()) {
773                eprintln!("{e}");
774                webview.app_handle().exit(1);
775            }
776        }
777    });
778    if modules.contains(Module::NavigationPolicy) {
779        plugin = plugin.on_navigation(move |_webview, url| match nav_hook.decide(url) {
780            NavDecision::Allow => true,
781            NavDecision::External { url } => {
782                let _ = opener::open_checked(opener_hook.as_ref(), &url);
783                false
784            }
785            NavDecision::Block => false,
786        });
787    }
788    plugin = plugin
789        .on_webview_ready(move |webview| {
790            let decision = gate_for_views.decide(webview.label());
791            // The DevTools policy is not a module: it applies to every webview, always.
792            // A denied webview whose DevTools could not be disabled is refused: it has been
793            // closed and reported, and nothing else is set up for it.
794            if devtools::apply_native(&webview, decision).is_err() {
795                return;
796            }
797            if harden_webviews {
798                harden::apply_native(&webview, &hardening_for_views, decision);
799            }
800            if let Err(e) = devtools::verify_not_remote_debuggable(webview.app_handle()) {
801                eprintln!("{e}");
802                webview.app_handle().exit(1);
803                return;
804            }
805            let app = webview.app_handle().clone();
806            let label = webview.label().to_owned();
807            lifecycle::enforce_never_show(&app, &ready_policy);
808            windows::with_placement_announced(&app, &label, || {
809                if restore_windows {
810                    if let Some(state) = app.try_state::<ShellState>() {
811                        let _ = state.windows.restore(&app, &label);
812                    }
813                }
814            });
815            if label == main_label
816                && lifecycle::show_on_ready_decision(
817                    show_on_ready_explicit,
818                    show_on_ready_module,
819                    lifecycle::launched_in_background(),
820                    lifecycle::qa_hidden(),
821                )
822            {
823                if let Some(w) = app.get_webview_window(&label) {
824                    // Through the guard: a never-show host gets nothing here.
825                    let _ = crate::windows::show_window(
826                        &app,
827                        &w,
828                        lifecycle::RevealSource::ShowOnReady,
829                        windows::RevealStyle::ShowOnly,
830                    );
831                }
832            }
833        })
834        .on_event(|app, event| {
835            if app.try_state::<ShellState>().is_some() {
836                lifecycle::run_event(app, event);
837            }
838        })
839        .setup(move |app, _api| {
840            let app = app.clone();
841            if let Err(e) = guard_result {
842                eprintln!("{e}");
843                return Err(Box::new(e));
844            }
845            match gate_result {
846                Ok(gate) => {
847                    app.manage(gate);
848                }
849                Err(e) => {
850                    eprintln!("{e}");
851                    return Err(Box::new(e));
852                }
853            }
854            setup(&app, cfg, nav, opener)
855        });
856    if harden_webviews {
857        plugin = match script_scope {
858            HardeningScript::AllFrames => {
859                plugin.js_init_script_on_all_frames(hardening.script_for(&content_labels))
860            }
861            // WebView2 injects even main-frame-only scripts into child frames. Guard the body
862            // as well as requesting native main-frame scope (WKWebView honors that scope).
863            HardeningScript::TopFrameOnly => plugin.js_init_script(format!(
864                "if (window.top !== window.self) {{ return; }}\n{}",
865                hardening.script_for(&content_labels)
866            )),
867            HardeningScript::Off => plugin,
868        };
869    }
870    for (scheme, policy) in streams {
871        plugin = plugin.register_asynchronous_uri_scheme_protocol(
872            scheme,
873            move |ctx, request, responder| {
874                let state = ctx.app_handle().state::<ShellState>();
875                stream::serve(&state.broker, &policy, &request, move |response| {
876                    responder.respond(response)
877                });
878            },
879        );
880    }
881    if let Some((scheme, policy)) = site {
882        plugin = plugin.register_asynchronous_uri_scheme_protocol(
883            scheme,
884            move |ctx, request, responder| {
885                let state = ctx.app_handle().state::<ShellState>();
886                site::serve_site_request(&state.broker, &policy, &request, move |response| {
887                    responder.respond(response)
888                });
889            },
890        );
891    }
892    plugin.build()
893}
894
895/// The claim token of the primary the single-instance plugin holds for the process lifetime.
896struct PluginClaim(u64);
897
898/// The startup error for an undecidable single-instance claim.
899fn unavailable_error(why: &str) -> Box<dyn std::error::Error> {
900    format!("rightkit-tauri-shell: single-instance could not be decided, refusing to start as a second primary: {why}").into()
901}
902
903/// An app id that cannot name the single-instance service: a programming error, so startup fails
904/// with the id and the rule instead of running with single-instance protection silently off.
905fn invalid_app_id_error(e: &instance::InvalidAppId) -> Box<dyn std::error::Error> {
906    format!("rightkit-tauri-shell: single-instance cannot start, app id is not usable: {e}").into()
907}
908
909/// The window-visibility policy a configuration declares.
910fn show_policy_of(cfg: &ShellBuilder) -> lifecycle::ShowPolicy {
911    lifecycle::ShowPolicy {
912        never_show: cfg.never_show,
913        never_show_env: cfg.never_show_env.clone(),
914        never_show_env_values: cfg.never_show_env_values.clone(),
915        reveal_on_reopen: cfg.reveal_on_reopen && cfg.modules.contains(Module::RevealOnReopen),
916    }
917}
918
919fn setup(
920    app: &AppHandle<Wry>,
921    cfg: ShellBuilder,
922    nav: Arc<NavPolicy>,
923    opener: Arc<dyn ExternalOpener>,
924) -> Result<(), Box<dyn std::error::Error>> {
925    // 0. Never-show: Prohibited before arbitration, menus, tray or any window exists.
926    let show_policy = show_policy_of(&cfg);
927    lifecycle::enforce_never_show(app, &show_policy);
928    // 1. Single-instance arbitration, before anything heavy.
929    let mut single = String::from("disabled");
930    let mode = lifecycle::instance_mode(
931        cfg.single_instance,
932        cfg.qa_instance_exempt,
933        lifecycle::qa_hidden(),
934    );
935    if mode == lifecycle::InstanceMode::QaExempt {
936        single = "qa-exempt".into();
937    }
938    if mode == lifecycle::InstanceMode::Arbitrate {
939        let for_cb = app.clone();
940        let custom = cfg.on_second_instance.clone();
941        let decider = cfg.on_second_instance_reveal.clone();
942        let version = app.package_info().version.to_string();
943        let identifier = app.config().identifier.clone();
944        match instance::arbitrate_with_legacy(
945            &cfg.app_id,
946            &version,
947            Some(identifier.as_str()),
948            Arc::new(move |s| {
949                lifecycle::route_second_instance(
950                    &for_cb,
951                    custom.as_ref(),
952                    &s.argv,
953                    &s.cwd,
954                    |app, argv, cwd| {
955                        lifecycle::on_second_instance(app, argv, cwd, decider.as_ref())
956                    },
957                )
958            }),
959        ) {
960            instance::Arbitration::Primary(h) => {
961                // Held for the process lifetime (or until `SingleInstance::destroy`).
962                let _claim = single_instance::hold(h);
963                single = "primary".into();
964            }
965            instance::Arbitration::Secondary => std::process::exit(0),
966            // Fail closed: setup errors abort startup instead of running as a second primary.
967            instance::Arbitration::Unavailable(why) => return Err(unavailable_error(&why)),
968            instance::Arbitration::InvalidAppId(e) => return Err(invalid_app_id_error(&e)),
969        }
970    }
971
972    // 2. Shared state.
973    let state_dir = match cfg.state_dir.clone() {
974        Some(d) => d,
975        None => app
976            .path()
977            .app_data_dir()
978            .map(|d| d.join("shell"))
979            .unwrap_or_else(|_| std::env::temp_dir().join(&cfg.app_id)),
980    };
981    let exe = std::env::current_exe().unwrap_or_default();
982    let tray_runtime = cfg.tray.as_ref().map(|t| tray::TrayRuntime {
983        icon: Mutex::new(None),
984        ids: Mutex::new(Default::default()),
985        show_label: t.show_label.clone(),
986        quit_label: t.quit_label.clone(),
987    });
988    #[cfg(feature = "engine")]
989    let engine = cfg.engine.clone().map(|s| {
990        Arc::new(engine::EngineHost::with_policy(
991            s,
992            cfg.engine_policy.clone(),
993        ))
994    });
995    // A lifecycle-only host owns its cache: repair is off unless it asked for it.
996    let cache_repair = if lifecycle::repair_cache_enabled(cfg.repair_cache, cfg.lifecycle_only) {
997        let dir = cfg
998            .cache_dir
999            .clone()
1000            .or_else(|| app.path().app_cache_dir().ok());
1001        dir.and_then(|d| recovery::ensure_cache_dir(&d).ok().map(|o| (d, o)))
1002    } else {
1003        None
1004    };
1005    let presence = Arc::new(presence::PresenceDnd::new());
1006    if cfg.modules.contains(Module::Presence) {
1007        let (handle, on_presence) = (app.clone(), cfg.on_presence.clone());
1008        presence::start_loop(Arc::clone(&presence), cfg.app_name.clone(), move |s| {
1009            use tauri::Emitter;
1010            let _ = handle.emit(EVENT_PRESENCE, s);
1011            if let Some(cb) = &on_presence {
1012                cb(&handle, s);
1013            }
1014        });
1015    }
1016    let recovery = if cfg.modules.contains(Module::Recovery) {
1017        cfg.recovery
1018            .clone()
1019            .map(|spec| recovery::RecoveryStore::new(spec, &state_dir))
1020    } else {
1021        None
1022    };
1023    let stream_schemes: Vec<String> = if cfg.modules.contains(Module::Stream) {
1024        cfg.streams.iter().map(|(s, _)| s.clone()).collect()
1025    } else {
1026        Vec::new()
1027    };
1028    let site_scheme = if cfg.modules.contains(Module::Stream) {
1029        cfg.site.as_ref().map(|(s, _)| s.clone())
1030    } else {
1031        None
1032    };
1033    let autostart_spec = autostart::AutostartSpec {
1034        app_id: cfg.app_id.clone(),
1035        app_name: cfg.app_name.clone(),
1036        executable: exe,
1037        options: cfg.autostart.clone(),
1038        legacy_plugin_name: cfg
1039            .autostart
1040            .migrate_from_plugin_entry
1041            .then(|| app.package_info().name.clone()),
1042    };
1043    if cfg.autostart.migrate_from_plugin_entry {
1044        let legacy = app.package_info().name.clone();
1045        match autostart::migrate_plugin_entry(&autostart_spec, &legacy) {
1046            Ok(_) => {}
1047            // A failed migration must not stop startup: the old entry stays until the next launch.
1048            Err(e) => eprintln!(
1049                "rightkit-tauri-shell: could not migrate the tauri-plugin-autostart login entry: {e}"
1050            ),
1051        }
1052    }
1053    if cfg.autostart.macos_backend == autostart::MacosBackend::ServiceManagement {
1054        // Move the shell's own LaunchAgent plist to SMAppService (macOS 13+ in a bundle; a no-op elsewhere).
1055        if let Err(e) = autostart::migrate_launch_agent(&autostart_spec) {
1056            eprintln!("rightkit-tauri-shell: could not move the LaunchAgent login item to SMAppService: {e}");
1057        }
1058    }
1059    app.manage(ShellState {
1060        main_thread_id: std::thread::current().id(),
1061        app_id: cfg.app_id.clone(),
1062        app_name: cfg.app_name.clone(),
1063        main_label: cfg.main_label.clone(),
1064        nav,
1065        opener,
1066        frameless: cfg.frameless && cfg.modules.contains(Module::Frameless),
1067        close_to_tray: cfg.close_to_tray,
1068        hide_to_background: cfg.hide_to_background,
1069        on_visibility: cfg.on_visibility.clone(),
1070        activation: Default::default(),
1071        show_policy,
1072        quit: Arc::new(quit::QuitGate::new(cfg.quit_drain)),
1073        windows: Arc::new(window_state::WindowStateStore::new(state_dir)),
1074        deep: Default::default(),
1075        schemes: cfg.schemes.clone(),
1076        frontend_ready: AtomicBool::new(false),
1077        exiting: AtomicBool::new(false),
1078        traffic: Default::default(),
1079        autostart: autostart_spec,
1080        menu_items: Mutex::new(HashMap::new()),
1081        menu_rows: Mutex::new(Vec::new()),
1082        tray: tray_runtime,
1083        on_menu: cfg.on_menu.clone(),
1084        #[cfg(feature = "engine")]
1085        engine: engine.clone(),
1086        hosted: Mutex::new(None),
1087        single_instance: Mutex::new(single),
1088        broker: Arc::new(PathBroker::with_app_name(&cfg.app_name)),
1089        notifications: notify::NotificationState::new(),
1090        presence,
1091        idle: cfg.idle_after.map(|d| {
1092            // `webview_idle(d)` replaces only the timeout; a provider set on the options is kept.
1093            let mut options = cfg.idle_options.unwrap_or_default();
1094            options.suspend_after = d;
1095            idle::IdleHost::new(d)
1096                .with_options(options)
1097                .with_on_error(cfg.idle_on_error.clone())
1098                .with_on_transition(cfg.idle_on_transition.clone())
1099        }),
1100        recovery,
1101        cache_repair: Mutex::new(cache_repair),
1102        stream_scheme: stream_schemes.first().cloned(),
1103        stream_schemes,
1104        site_scheme,
1105        site_policy: cfg
1106            .site
1107            .as_ref()
1108            .map(|(_, p)| p.clone())
1109            .unwrap_or_default(),
1110        modules: cfg.modules,
1111        clipboard: cfg.clipboard,
1112        clipboard_options: cfg.clipboard_options,
1113        clipboard_dir: clipboard::clipboard_temp_dir(&cfg.app_id),
1114    });
1115
1116    // 3. Native menu.
1117    if let Some(spec) = &cfg.menu {
1118        let built = menu::build(
1119            app,
1120            &MenuSpec {
1121                app_name: cfg.app_name.clone(),
1122                ..spec.clone()
1123            },
1124        )?;
1125        let state = app.state::<ShellState>();
1126        *state.menu_rows.lock().unwrap_or_else(|e| e.into_inner()) = menu::describe(&built.menu);
1127        *state.menu_items.lock().unwrap_or_else(|e| e.into_inner()) = built.items;
1128        app.set_menu(built.menu)?;
1129        app.on_menu_event(|app, event| lifecycle::on_menu_event(app, event.id().as_ref()));
1130    }
1131
1132    // 4. Tray.
1133    if let Some(spec) = &cfg.tray {
1134        let icon = tray::install(app, spec, &cfg.main_label)?;
1135        let state = app.state::<ShellState>();
1136        if let Some(rt) = &state.tray {
1137            *rt.icon.lock().unwrap_or_else(|e| e.into_inner()) = Some(icon);
1138        }
1139    }
1140
1141    // 5. Engine, off the UI thread.
1142    #[cfg(feature = "engine")]
1143    if let Some(engine) = engine {
1144        let handle = app.clone();
1145        let lifecycle_events = engine.subscribe_lifecycle();
1146        let supervised = lifecycle_events.is_some();
1147        if let Some(events) = lifecycle_events {
1148            let handle = app.clone();
1149            let host = Arc::downgrade(&engine);
1150            std::thread::Builder::new()
1151                .name("rk-shell-engine-events".into())
1152                .spawn(move || {
1153                    use tauri::Emitter;
1154                    loop {
1155                        let Some(host) = host.upgrade() else { break };
1156                        if host.is_shutting_down() { break; }
1157                        match events.recv_timeout(std::time::Duration::from_millis(250)) {
1158                            Ok(event) => {
1159                                let _ = handle.emit(EVENT_ENGINE, serde_json::json!({
1160                                    "call": "$lifecycle", "event": host.lifecycle(), "supervisor": event
1161                                }));
1162                            }
1163                            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
1164                            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
1165                        }
1166                    }
1167                })?;
1168        }
1169        if let Some(events) = engine.subscribe_events() {
1170            let handle = app.clone();
1171            let host = Arc::downgrade(&engine);
1172            std::thread::Builder::new()
1173                .name("rk-shell-engine-unsolicited".into())
1174                .spawn(move || {
1175                    use tauri::Emitter;
1176                    loop {
1177                        let Some(host) = host.upgrade() else { break };
1178                        if host.is_shutting_down() {
1179                            break;
1180                        }
1181                        match events.recv_timeout(std::time::Duration::from_millis(250)) {
1182                            Ok(frame) => {
1183                                let _ = handle.emit(
1184                                    EVENT_ENGINE,
1185                                    engine::EngineEvent {
1186                                        call: "$unsolicited".into(),
1187                                        id: None,
1188                                        tag: None,
1189                                        event: frame["event"].clone(),
1190                                    },
1191                                );
1192                            }
1193                            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
1194                            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
1195                        }
1196                    }
1197                })?;
1198        }
1199        std::thread::Builder::new()
1200            .name("rk-shell-engine".into())
1201            .spawn(move || {
1202                use tauri::Emitter;
1203                let payload = match engine.start() {
1204                    Ok(hello) => serde_json::json!({"state": "ready", "hello": hello}),
1205                    Err(e) => {
1206                        serde_json::json!({"state": "error", "code": e.code, "message": e.message})
1207                    }
1208                };
1209                engine.set_lifecycle(payload.clone());
1210                if !supervised {
1211                    let _ = handle.emit(
1212                        EVENT_ENGINE,
1213                        serde_json::json!({"call": "$lifecycle", "event": payload}),
1214                    );
1215                }
1216            })?;
1217    }
1218
1219    // 6. Watchdog.
1220    if cfg.modules.contains(Module::Watchdog) {
1221        if let Some(w) = cfg.watchdog.clone() {
1222            watchdog::install(app.clone(), w);
1223        }
1224    }
1225
1226    // 8 (queue only). Links on the launch argv wait for the frontend.
1227    let argv: Vec<String> = std::env::args().skip(1).collect();
1228    let state = app.state::<ShellState>();
1229    state
1230        .deep
1231        .push_all(argv.iter().map(String::as_str), &state.schemes);
1232    notify::set_default_app_id(&app.config().identifier);
1233    open_url::deliver(&argv);
1234    Ok(())
1235}
1236
1237/// Build the `invoke_handler` with the shell commands plus the app's own.
1238/// `rightkit_tauri_shell::handler![my_command, other]`.
1239#[macro_export]
1240macro_rules! handler {
1241    ($($app_cmd:path),* $(,)?) => {
1242        ::tauri::generate_handler![
1243            $crate::commands::rk_shell_open_external,
1244            $crate::commands::rk_shell_ready,
1245            $crate::commands::rk_shell_status,
1246            $crate::commands::rk_shell_window,
1247            $crate::commands::rk_shell_open_window,
1248            $crate::commands::rk_shell_set_tray,
1249            $crate::commands::rk_shell_set_menu_item_enabled,
1250            $crate::commands::rk_shell_autostart,
1251            $crate::commands::rk_shell_open_login_items_settings,
1252            $crate::commands::rk_shell_clipboard_read,
1253            $crate::commands::rk_shell_clipboard_write,
1254            $crate::commands::rk_shell_engine_call,
1255            $crate::commands::rk_shell_engine_call_tagged,
1256            $crate::commands::rk_shell_engine_status,
1257            $crate::commands::rk_shell_traffic_lights,
1258            $crate::commands::rk_shell_quit,
1259            $crate::commands::rk_shell_finish_quit,
1260            $crate::commands::rk_shell_capabilities,
1261            $crate::commands::rk_shell_set_theme,
1262            $crate::commands::rk_shell_notify,
1263            $crate::commands::rk_shell_notify_status,
1264            $crate::commands::rk_shell_notify_clear,
1265            $crate::commands::rk_shell_badge,
1266            $crate::commands::rk_shell_set_dnd,
1267            $crate::commands::rk_shell_presence,
1268            $crate::commands::rk_shell_presence_prefs,
1269            $crate::commands::rk_shell_presence_probe,
1270            $crate::commands::rk_shell_presence_evaluate,
1271            $crate::commands::rk_shell_idle_status,
1272            $crate::commands::rk_shell_idle_note,
1273            $crate::commands::rk_shell_path_require,
1274            $crate::commands::rk_shell_path_revoke,
1275            $crate::commands::rk_shell_path_grant_recent,
1276            $crate::commands::rk_shell_path_regrant_saved,
1277            $crate::commands::rk_shell_stream_grant,
1278            $crate::commands::rk_shell_recovery_save,
1279            $crate::commands::rk_shell_recovery_load,
1280            $crate::commands::rk_shell_recovery_clear,
1281            $crate::commands::rk_shell_clipboard_item,
1282            $crate::commands::rk_shell_site_grant,
1283            $crate::commands::rk_shell_site_revoke
1284            $(, $app_cmd)*
1285        ]
1286    };
1287}