rightkit-tauri-shell 0.4.0

Shared Tauri desktop shell for Right Suite apps: webview hardening, window state, native menu, tray, single instance, deep links, autostart, rich clipboard, watchdog and engine wiring.
Documentation

rightkit-tauri-shell

Shared Tauri desktop shell for Right Suite applications. It owns hardened webviews (HardeningScript, navigation and popup policy), window state, the native menu and tray, single-instance arbitration, deep links, autostart, rich clipboard, notifications, a watchdog, presence, and optional engine sidecar wiring. Its entry point is Shell::builder(app_id).build().plugin(), with lower-level modules public.

Use it in a Right Suite Tauri app's native host, so every app gets the same shell behavior.

Do not use it outside a Tauri 2 application, or as a replacement for an app's own invoke handlers and product logic. Choose one plugin entry point per app.

Features

Feature Default Pulls in Effect
engine off rightkit-job-queue (optional; rightkit-child-process is an unconditional dependency) Engine sidecar wiring over rightkit-job-queue EngineClient. Opt in; it pulls rightkit-job-queue (db, time, uuid).
wv2-0_38 on windows 0.61, webview2-com 0.38 Windows types for Tauri 2.11 (the default). Must match the app's Tauri.
wv2-0_39 off windows 0.62, webview2-com 0.39 Windows types for Tauri 2.12. With default-features = false, enable wv2-0_39 (plus engine if used). Enabling both selects 0.39.
content-devtools off tauri/devtools Required by DevToolsPolicy::ContentOnly. Only an app on the right-release allow-list (today ViewRight) may ship it; see "DevTools policy". Never a default feature.
dev-overrides off none Development and QA builds only: NavPolicy::default() treats loopback (localhost, 127.0.0.1, [::1]) http(s) URLs as internal. Not implied by a build profile (installed dev builds with debug assertions get the release default, allow_loopback: false); an app sets it from an optional QA/dev feature. Never a default feature.
dev-inspect off tauri/devtools Development builds only: every webview is inspectable and the DevTools guards stand down; see "DevTools policy / Development builds". A build with it can never be released. Never a default feature, and not needed by ContentOnly.

Mismatched wv2 features put two copies of windows or webview2-com beside Tauri's, and cast() then fails. With default-features = false, enable exactly the feature that matches the app's Tauri version.

Platform support

  • macOS: native notifications and presence through objc2 and related crates; cfg(target_os = "macos") in src/ (for example notify.rs, presence.rs, frameless.rs).
  • Windows: WebView2 types through the wv2 features; native toasts through tauri-winrt-notification; cfg(windows) in src/.
  • Linux: this README does not describe Linux-specific behavior. Open-URL delivery on Linux depends on the .desktop MIME entry (see Standalone open-URL hook).

Published version: see INDEX.md at the repository root. See CHANGELOG.md; the version sections below record additions by version.

Example

Illustrative; not compiled as a doctest. The compiled references are tests/policy.rs (policy decisions) and the example-app/ Tauri project in this crate.

Windows

  • Generation: src/lib.rs re-exports one windows and webview2-com pair as crate::winapi. wv2-0_38 selects windows 0.61 and webview2-com 0.38 (default). wv2-0_39 selects 0.62 and 0.39. Enabling both picks 0.39. With neither, the crate fails with compile_error!. Tauri 2.12 apps use default-features = false, features = ["wv2-0_39"] (add "engine" for the sidecar).
  • The Cargo.toml comment ties 0.38 to Tauri 2.11 and 0.39 to Tauri 2.12. The Tauri lock versions are not checked here: the example app's Cargo.lock is stale and records rightkit-tauri-shell 0.2.0 (example-app/Cargo.lock:2692; the crate is 0.3.0).
  • WebView2 hardening (src/harden.rs, windows_impl): sets status bar, zoom control, pinch zoom, swipe navigation and browser accelerator keys (F5, Ctrl+R, Alt+Left, F12). It registers ContextMenuRequested through ICoreWebView2_11. That handler suppresses the menu unless the target is editable and allow_editable_menu is set.
  • Idle (src/idle.rs): ICoreWebView2_3::TrySuspend and SetIsVisible on the controller.
  • Clipboard (src/clipboard.rs): OpenClipboard with 10 retries at 20 ms. Reads CF_UNICODETEXT, HTML Format, PNG, CF_HDROP (file lists through DragQueryFileW) and CF_DIB. A write owns the clipboard through a message-only window, because SetClipboardData fails after EmptyClipboard with a NULL owner. ClipboardOptions::image_preference chooses PNG first (default) or CF_DIB first for the typed image.
  • Notifications: WinRT toasts through tauri-winrt-notification 0.8, as described below.
  • Registry and shell: autostart reads and writes HKCU\...\CurrentVersion\Run through reg.exe. Scheme registration writes under HKCU\Software\Classes through reg.exe. Both use CREATE_NO_WINDOW. opener launches rundll32.exe url.dll,FileProtocolHandler.
  • Single instance uses the rightkit-daemon named pipe (src/instance.rs). Broker entropy comes from SystemFunction036 in advapi32 (src/broker.rs).
  • Dependencies: windows_061/windows_062 and webview2_com_038/webview2_com_039 are optional. They enable Win32 features Win32_Foundation, Win32_System_Com, Win32_System_DataExchange, Win32_System_Memory, Win32_System_Ole, Win32_UI_Shell and Win32_UI_WindowsAndMessaging. Consumers select only a wv2-* feature.
  • Caveat: harden.rs, clipboard.rs and idle.rs are cfg(windows) code and are not compiled or exercised on the macOS host (src/idle.rs:9). The clipboard backend has not been run against a live Windows clipboard, so verify copy and paste on a Windows machine before relying on it.

DevTools policy

The owner's fixed design, verbatim, never to be changed: every Right app has absolutely NO access to DevTools: blocked at each and every level that is possible, for the app, its shell and its own UI. The single exception is ViewRight: it opens HTML documents, and DevTools must be available INSIDE that opened HTML content only (the content frame), never for ViewRight's own UI or shell.

Source: src/devtools.rs (OWNER_DECISION). Written, unverified: nothing here was compiled or run; the macOS and Windows calls are the uncertain part.

API

  • DevToolsPolicy::Denied (the default) and DevToolsPolicy::ContentOnly (exactly two values). Shell::builder(id).devtools_policy(..).
  • ContentWebview::new(label, window_label).capability(json)...app_acl_manifest(true) and Shell::builder(id).content_webview(..) register a webview that renders opened content. Registration is refused (typed DevToolsError, startup fails, loud stderr line) when: the policy is Denied; the content-devtools feature is not compiled; the label is the main webview (never allowed); a supplied capability document applies to the content webview by webview label or by the label of its parent window and grants permissions; a capability document cannot be parsed; the app did not attest an application ACL manifest.
  • devtools::guard_process_start() runs when the plugin is built and may be called first in main. devtools::open_content_devtools(app, label) is the only path to an open inspector and refuses anything but a registered content webview.
  • The old Devtools enum and Hardening::devtools are removed: there is no "on" and no "debug builds" any more.
  • The policy is not a Module: opting out of Module::JsHardening does not opt out of it.

What each layer blocks

Layer macOS (WKWebView) Windows (WebView2)
Per webview, native isInspectable = false (macOS 13.3+, guarded by respondsToSelector) and the private developerExtrasEnabled preference set to false explicitly, on every webview the shell sees, including debug builds. This removes Safari Develop attach, "Inspect Element" and WebKit's Cmd+Opt+I handler. close_devtools runs first where Tauri has one. AreDevToolsEnabled = false, read back; when it cannot be set or still reads on, the webview is closed and DevToolsError::NativePolicyNotApplied is reported (apply_native returns it when Tauri ran the native step at once, otherwise it is printed). One AcceleratorKeyPressed handler per webview stops exactly the keys the active rules name: F12, Ctrl+Shift+I/J/C and Ctrl+U in a denied webview; F5, Ctrl+R and Ctrl+Shift+R only with the hardening's block_reload; Alt+Left/Right and the back/forward keys only with block_history_nav; Ctrl with plus, minus or zero only with block_zoom. Browser keys are never turned off as a whole, so Ctrl+P and Ctrl+F always pass. A ContextMenuRequested handler removes every menu item whose name contains "inspect" from every menu, including the editable-field menu the hardening keeps. Each webview has one handler of each kind, however often it becomes ready.
Page script F12, Ctrl+Shift+I/J/C, Cmd+Opt+I/J/C/U, Ctrl+Alt+I/J/C/U cancelled in every frame of every non-content webview (matched by KeyboardEvent.code, so Option-letter dead keys do not slip through). Same.
Process start Refuses a launch argument that enables remote debugging. Same, plus: removes the WebView2 additional-browser-arguments and script-debugger-pipe environment variables from the process; refuses to start when the AdditionalBrowserArguments machine or user policy (HKLM in the 64-bit and the 32-bit registry view, HKCU) injects a debugging switch, or when one of those keys exists and cannot be read. The keys are read through the registry API; no program is started for it. Only a key that does not exist counts as "no policy".
After creation none A fresh DevToolsActivePort file in the app's local or roaming data folder (or its EBWebView) means a debugging port is open: the process logs and exits 1. The folders looked in are the app's local and roaming data, the folder a UserDataFolder policy names for this program, the folder the WebView2 user-data environment variable names, and any folder the app registered with devtools::register_user_data_dir (needed when a webview is given its own data_directory). Checked when a webview becomes ready and once more when the first page has finished loading.

What cannot be blocked

  • Windows machine policy. WebView2 appends policy and environment arguments to whatever the app passes (Microsoft's WebView2 documentation; not fetched or checked in this repository), so an explicit argument string cannot override them. The environment variable is process-local and is removed. The registry policy is not ours: the shell detects it at startup and refuses to run. An administrator can still write the key after the check; the port-file tripwire is the second line. Elevated host apps ignore the environment variable (Microsoft's WebView2 documentation; not fetched or checked in this repository), so this matters most for non-elevated apps.
  • A debugger attached to the process (lldb/Visual Studio, get-task-allow, Time Travel Debugging) and UI automation / accessibility are outside "DevTools" and outside this crate. The hardened runtime and signing checks in right-release are the control for the macOS entitlement.
  • macOS before 13.3 has no isInspectable; only the developerExtrasEnabled preference and the absence of get-task-allow apply.
  • The user's own WebKitDeveloperExtras default is overridden by the explicit per-webview preference; this ordering is unverified.
  • Linux is not covered by native settings (Tauri on Linux is not a supported Right target).

ContentOnly

ContentOnly makes only registered content webviews inspectable: isInspectable = true + developerExtrasEnabled = true (macOS); AreDevToolsEnabled = true with a context menu trimmed to the Inspect item, accelerator keys still off, so the inspector is opened from that item or from open_content_devtools (Windows). The shell's DOM, Console and Sources are never reachable because the shell is a different WKWebView / CoreWebView2 whose own setting stays off.

Iframes: inspection of an iframe alone cannot be granted. On both platforms the inspector attaches to a whole webview, so a page whose content is an iframe in the app UI webview would make the UI inspectable. Content must live in its own webview. ViewRight renders HTML previews as iframes today and does not meet this rule; see docs/plans/2026-10-11-viewright-content-devtools-design.md.

Adoption

  • Every app: nothing to call. Do not enable Tauri's devtools feature, do not use Devtools::* (removed), do not create webviews with .devtools(true).
  • An app that created webviews itself: they pass through on_webview_ready like any other and are denied.
  • ViewRight only: enable content-devtools, devtools_policy(ContentOnly), register each content webview, declare an application ACL manifest in build.rs, scope its capabilities with webviews (not windows) and set "devtools": "content-only" in its right-release config.
  • right-release refuses devtools in a release or update build for any app that is not allow-listed (packages/release/devtools-policy.json).

Development builds

The owner's rule for shipped apps is unchanged: no DevTools, ViewRight's content frame excepted. Developers and the QA tooling still need to inspect a development build, so exactly one switch exists, the cargo feature dev-inspect (off by default, never reached from default, and not needed by ContentOnly; a content-devtools release build never compiles it).

  • The pipe form of remote debugging leaves no port file. Started with the remote-debugging pipe switch, a browser takes its debugger over inherited handles: it opens no port and writes no DevToolsActivePort, so the tripwire above cannot see it. The switch is refused where it can be seen (the process's own arguments, the AdditionalBrowserArguments policy, the scrubbed environment variables). A parent process that starts the browser itself with that switch is outside what this crate can detect.
  • BrowserExecutableFolder policy (Windows). A machine or user policy that makes WebView2 start a browser from another folder for this program is refused at start (DevToolsError::BrowserRuntimeRedirected): that browser may be built to do anything. Matched value names: * and this program's executable file name, compared without regard to case; every other name belongs to another program and is ignored. The environment variable of the same purpose, WEBVIEW2_BROWSER_EXECUTABLE_FOLDER, is refused the same way when it is set in the process environment at start.
  • WebView2 environment variables (Windows). Inspected at start: the additional-browser-arguments variable and the script-debugger-pipe variable (both removed from the process), WEBVIEW2_BROWSER_EXECUTABLE_FOLDER (refused when set), WEBVIEW2_USER_DATA_FOLDER (left in place; the folder it names is watched by the port-file tripwire). Knowingly not inspected: WEBVIEW2_RELEASE_CHANNEL_PREFERENCE and the other channel-selection variables, which choose among Microsoft's own runtimes.
  • Only under dev-inspect: the policy for the app's webviews becomes "developer inspection allowed". Every webview, the app UI included, is inspectable (WebviewDecision::AllowDev; macOS isInspectable and developerExtrasEnabled true, Windows AreDevToolsEnabled true, menus and accelerator keys left as the hardening set them); guard_process_start stops scrubbing WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS, stops refusing a debugging launch argument or registry policy, and the DevToolsActivePort tripwire does not run, so a remote-debugging port (the wdio WebDriver driver of qa-native builds) keeps working. The build embeds the marker RIGHTKIT-SHELL-DEV-INSPECT-COMPILED (DEV_INSPECT_MARKER, a #[used] static read from startup code, the technique RIGHTKIT-QA-CONTROL-SERVER-PRESENT uses) and prints one stderr line at startup beginning RIGHTKIT SHELL DEVELOPER INSPECTION ACTIVE.
  • Without the feature nothing changes: Denied / ContentOnly exactly as above, in every build profile (debug assertions do not matter, because installed dev builds are release-profile builds).
  • Selecting it: an app adds an optional feature that its default features never reach, dev-native = ["rightkit-tauri-shell/dev-inspect"] (beside qa-native), and builds its development lane with --features dev-native.
  • Such a build can never be released, three layers: (1) right-release refuses dev-inspect (and dev-native) in a release or update build command, package script, or Cargo manifest, directly, through another feature, through default, or via --all-features (qa-build-features.mjs); (2) the binary scan refuses the marker in every release and update artifact, including inside MSIX/ZIP containers, and no devtools: "content-only" allowance lifts it (qa-control-scan.mjs, rule shell-dev-inspect); (3) the rightkit-qa native QA guard rejects dev-inspect / dev-native reachable from default features, a non-optional rightkit-tauri-shell that lists it, and release-named package scripts that enable it. The development lane (right-release dev) never runs layers 1 and 2.

Hidden hosts: never_show, reopen opt-out, idle options

  • Shell::builder(..).never_show(true) and .never_show_env("RIGHTKIT_QA_NATIVE_REAL"): the shell shows, unminimises and focuses nothing under any event, and macOS runs Prohibited from plugin setup. Every reveal path goes through lifecycle::reveal_mode; the host's own direct window calls are outside it.
  • .reveal_on_reopen(false) (or ShellPlugin::builder().without(Module::RevealOnReopen)): Dock reopen and opened URLs no longer reveal; links still deliver.
  • never_show_env(name) counts only 1, true, yes, on (case-insensitive, exact); other values are off. never_show_env_value(name, "token") matches that exact value only.
  • Idle ordering (Windows): suspend = SetIsVisible(false) then TrySuspend, skipped if the hide failed; resume = Resume then SetIsVisible(true), skipped if the resume failed; failures reach webview_idle_on_error and are retried on the next resume; resume does nothing if this shell did not hide or suspend. webview_idle_on_transition(|t| ..) logs real suspends and resumes with elapsed time; idle::note_minimized_after takes a per-minimise delay.
  • .webview_idle_options(IdleOptions { start_on_minimize: true, controller_hide: ControllerHide::OnTimer, events: IdleEvents::NativeSuspendOnly, ..Default::default() }). Defaults match webview_idle(30 s).
  • IdleOptions::default().suspend_after_with(|| Some(read_delay())) reads the suspend delay at each hide (None or a panic uses the fixed suspend_after).
  • IdleOptions::default().on_minimise(true) (IdleTrigger::Minimised): idle only when the window is minimised (Windows: SetIsVisible(false) then TrySuspend, restore is Resume then SetIsVisible(true)); a tray hide does not start it. For a single-web-view app; macOS and Linux get the event only.
  • lifecycle_only leaves ShowOnReady and the cache repair off unless the host calls show_on_ready(true) / repair_cache(true).

Also (0.3.0, host-decided lifecycle)

  • Quit drain: quit_drain(true) / quit_drain_bound(d); the renderer (@rightkit/tauri-shell 0.1.1) answers rightkit-shell://quit-requested and calls rk_shell_finish_quit. Default bound 15 s, max 120 s; off by default.
  • close_behavior(CloseBehavior::HideToBackground), on_visibility, qa_instance_exempt, second_instance_reveal, show_on_ready (explicit beats RIGHTKIT_QA_HIDDEN), internal_scheme.
  • Shell::lifecycle_plugin(): the lifecycle without hardening, navigation policy, presence, watchdog or window state.
  • WindowStateStore standalone with restore_with; AutostartOptions::{startup_wm_class, config_home, macos_unsupported}.
  • macOS login item through SMAppService.mainApp (macOS 13+): AutostartOptions::macos_backend(MacosBackend::ServiceManagement) (default LaunchAgent, unchanged). rk_shell_autostart then returns loginItem (notRegistered | enabled | requiresApproval | notFound); on requiresApproval (enabled is false) call rk_shell_open_login_items_settings / login_item::open_login_items_settings() to send the user to System Settings > Login Items. Needs a signed .app bundle; carries no launch arguments (detect the login launch yourself) and no LowPriorityIO (use login_item::set_background_io). An existing LaunchAgent plist is removed and replaced at startup; macOS 12 and older, an unbundled executable and a redirected RIGHTKIT_SHELL_HOME / config_home keep the LaunchAgent. Mac-only behaviour is verified on a Mac.
  • NotificationRequest.bypassDoNotDisturb; rk_shell_capabilities.

Also (0.3.0)

Modular install and the broker as a library type

use rightkit_tauri_shell::{Module, Shell, ShellPlugin};

// Everything except presence and the watchdog:
let plugin = ShellPlugin::builder()
    .shell(Shell::builder("viewright").build())
    .without(Module::Presence)
    .without(Module::Watchdog)
    .build();

// Only broker, stream, recovery, frameless and clipboard state:
let plugin = ShellPlugin::builder()
    .shell(Shell::builder("viewright").build())
    .install_state_only();

An opted-out command family answers unsupported. PathBroker needs no plugin: create one in your own state and call its grant and require_* methods from your own commands.

Site-root scheme

ShellBuilder::site_protocol("viewright-site", SitePolicy::default()) serves allowlisted subresources beneath a site root. The app grants it for a document it already holds with rk_shell_site_grant (which returns {token, root, baseUrl}) and ends it with rk_shell_site_revoke. Denied names and the MIME allowlist are part of SitePolicy.

Typed clipboard, recovery, idle and single instance

  • rk_shell_clipboard_item returns {"kind": "files" | "image" | "text" | "none", ...}. Images are written to a granted temp file that is purged after clipboard_purge_after (default one hour).
  • RecoverySpec::documents().at_path(p) pins the snapshot file. RecoveryStore::migrate_legacy does a one-time, validated migration from an older path.
  • webview_idle_on_error(|failure| ..) reports failed main-webview suspend or resume calls.
  • SingleInstance::claim(id, on_message) returns ClaimOutcome::{Primary, Forwarded, Failed}. Treat Forwarded and Failed as exit paths. Failed never becomes a second primary.
  • startup_cache::ensure_http_cache_dir(identifier) repairs the user cache directory for the app. ensure_http_cache_dir_with(identifier, || dirs::cache_dir()) does the same under the root your Tauri resolves (dirs::cache_dir, or the path resolver once an AppHandle exists).
  • Private clipboard for tests: clipboard::take_item_from_with(&Board, broker, temp_dir, policy, &options) is take_item_with for an explicit board (take_item_with is its Board::General wrapper). With Board::Named(name) on macOS it reads only that private pasteboard and never touches the general clipboard. The AppleScript fallbacks (a file URL without a path, a PNG coerced from a TIFF) read the general clipboard, so they are off for a named board and nothing falls back to it: a missing type or an empty board gives ClipboardItem::None, never the user's clipboard. Stage content with clipboard::write(&Board::Named(name), ..). Use a unique name (for example <app>-test-<pid>-<n>); the Apple CFPasteboard ... names are the system boards (general, find, font, ruler, drag) and are refused by read, write, formats and take_item_from_with, as is an empty name. Windows has one clipboard and no private one: a named board is rejected there (read, write, formats and take_item_from_with return an error), so a test that needs a private board is macOS-only. Other platforms reject it too.
  • QA controls (non-default feature qa-probes, development/QA builds only): clipboard::qa::paste_from_board(&broker, app_id, raw) reads a private named pasteboard as typed JSON, and clipboard::qa::stage_board(raw, prefix) stages a file URL and/or a generated PNG on a board whose name starts with the app's QA prefix; both refuse Apple CFPasteboard ... names (clipboard::named_board_refusal) before touching a pasteboard. Register them as rightkit-qa control commands; see example-app/src/main.rs and example-app/scenarios/.
  • frameless::button_frames(&window) is a read-only macOS probe of the close, minimise and zoom buttons (frame, hidden flag, window visibility), read on the main thread; it returns ShellError::Unsupported on other platforms. It is a QA probe: compiled only with the non-default qa-probes feature in a debug-assertions build; a release build that enables the feature fails with compile_error!.
  • Clipboard reads: clipboard::read no longer copies bytes of unrecognised pasteboard types. Opt in with ClipboardOptions { custom_max_bytes: Some(n), .. } through clipboard::read_with or ShellBuilder::clipboard_options. On macOS text also falls back to the other plain-text types (NSPasteboardTypeString coercion, NSStringPboardType, UTF-16 and generic plain text), as pbpaste does. ClipboardItem::None still serialises as "none"; an app whose front end expects "empty" maps it.
  • StreamPolicy::default().observer(|event| ..) receives StreamEvent { mime, kind } with StreamEventKind::{Resolved, Head, RangeServed, FullServed, RangeRejected, BytesRead}. Unset costs one branch per request; a panicking observer is caught and never reaches the response.
  • frameless::TrafficLights is a plain value (Default, Clone). An app without the shell plugin can app.manage(TrafficLights::default()), call set_offset (relative to the macOS default) or set_inset (absolute: the close button's top-left corner x points from the left and y points from the top) from its own command and forward window events to handle_window_event; no ShellState, lifecycle hook or single-instance claim is needed. The shell's own app-menu quit item (macOS "Quit ", Windows/Linux "Exit ") routes to the ordered quit, so close-to-tray no longer swallows Cmd+Q. LeftClick::Show reveals the window on a tray left click and never hides it.
  • Navigation: OAUTH_POPUP_ORIGINS now includes the Atlassian and Salesforce sign-in origins. NavPolicy::decide_window_open_pinned admits a popup only from a given pinned opener origin; decide_window_open is unchanged and still returns Deny for a non-web scheme, and NavPolicy::protocol_consent_scheme names the scheme an app with a consent surface may confirm. The shell's own windows pass the opening window's URL to the popup decision. nav::strip_coop_headers and nav::popup_origins_from_json are pure helpers for the popup response hook and an app's popup-origin setting.

Relaunch for update, and the legacy plugins' names

SingleInstance::destroy() releases every claim this process holds through the shell (the standalone claim and the plugin's primary), then runs each release registered with SingleInstance::on_destroy, once. It is idempotent. It never touches a tauri-plugin-single-instance claim by itself: ClaimOptions::legacy_identifier only probes for a legacy holder, and the plugin's mutex, window, socket or D-Bus name belongs to the plugin's own state in the app. An app that still registers the plugin makes one call release both:

let handle = app.clone();
SingleInstance::on_destroy(move || tauri_plugin_single_instance::destroy(&handle));
// relaunch-for-update, after spawning the helper:
SingleInstance::destroy();
app.exit(0);

Without the hook, call tauri_plugin_single_instance::destroy(&app) and SingleInstance::destroy(), both, before exiting. On Windows the plugin closes its mutex (which frees the name) but its DestroyWindow only works on the thread that created the window, so the hidden window can outlive the call until exit; the relaunch is not blocked, since arbitration is the mutex.

Exact legacy names (LegacySingleInstanceNames, LegacyAutostartNames), read from tauri-plugin-single-instance 2.4.2 and 2.4.3, tauri-plugin-autostart 2.5.1, auto-launch 0.5.0. HeardRight (identifier app.heardright.next, productName HeardRight):

Item Derivation HeardRight
Windows mutex / window class / title <identifier>-sim / -sic / -siw app.heardright.next-sim / -sic / -siw
macOS socket /tmp/<identifier with . and - as _>_si.sock /tmp/app_heardright_next_si.sock
Linux D-Bus name (not probed) <identifier>.SingleInstance app.heardright.next.SingleInstance
Windows Run value HKCU\Software\Microsoft\Windows\CurrentVersion\Run, value <entry name> HeardRight
macOS LaunchAgent label <entry name>, ~/Library/LaunchAgents/<entry name>.plist HeardRight, ~/Library/LaunchAgents/HeardRight.plist
Linux autostart ~/.config/autostart/<entry name>.desktop HeardRight.desktop

The entry name is the plugin builder's app_name, else PackageInfo::name, which is productName when set and the Cargo package name otherwise. The names assume the plugins' semver feature is off (HeardRight does not enable it). The plugin also writes a same-named value under ...\Explorer\StartupApproved\Run; the migration leaves it. HeardRight's lockfile pins tauri-plugin-single-instance 2.4.5, whose source was not available to read; 2.4.2 to 2.4.3 changed only the macOS listener (tokio), not the names.

Also (0.1.3)

Single-instance arbitration only

tauri::Builder::default()
    .plugin(rightkit_tauri_shell::Shell::builder("viewright")
        .build()
        .single_instance_plugin(|app, argv, cwd| {
            // Handle forwarded files & focus using your app's own policy.
        }));

Add this plugin first. It uses only app_id from the builder & always enables arbitration. It retains the primary service guard until exit, forwards argv/cwd to the primary callback & exits a successful secondary launch. Unavailable arbitration fails startup (the plugin setup returns an error): the app never runs as a second primary. instance::arbitrate returns Arbitration::Unavailable for OS-level failures and Arbitration::InvalidAppId for an unusable id; the caller must treat neither as "single instance off". The callback owns all second-launch behavior, including URL delivery & focus.

This entry point installs no hardening script, native hardening, navigation policy, window-state restoration, presence loop or ShellState. Use your own invoke handler; rightkit_tauri_shell::handler! requires the full plugin. Choose one plugin entry point per app. The lower-level instance::arbitrate remains public.

Single-instance naming

The single-instance service is named <id>-shell (instance::arbitrate, the plugins) or <id>-instance (SingleInstance::claim), after the id is normalised: ASCII-lowercased, then every character outside [a-z0-9-] becomes -, one for one, nothing collapsed or trimmed. A dotted bundle identifier works: app.mailright becomes app-mailright, Mail_Right becomes mail-right. An id that is already [a-z0-9-] is unchanged, so running instances are still found across an upgrade. Distinct ids can normalise to one name (a.b and a-b), which makes them the same app for this purpose. rightkit-daemon allows 1 to 48 bytes of [a-z0-9-], so an empty id, or one longer than 42 bytes (-shell) or 39 bytes (-instance), is refused with Arbitration::InvalidAppId(InvalidAppId) or ClaimFailure::InvalidId, never with Unavailable or Host. instance::try_service_name(id) returns the name or the typed error. ShellBuilder::build still panics on any id outside [a-z0-9-] and 40 bytes.

Full-plugin script scope

use rightkit_tauri_shell::{HardeningScript, Shell};

let plugin = Shell::builder("viewright")
    .hardening_script(HardeningScript::TopFrameOnly)
    .build()
    .plugin();

ShellBuilder::hardening_script accepts AllFrames (unchanged default), TopFrameOnly or Off. Top-frame mode requests native main-frame injection & guards the script body with window.top !== window.self, so document iframes remain untouched even on WebView2, which injects scripts into subframes. Off registers no DOM hardening script. Native webview hardening, navigation policy & remaining full-shell behavior still apply in both modes.

Tests inspect generated plugin scripts for all three scopes & the isolated plugin, plus real service arbitration from a separate secondary process, argv/cwd forwarding & guard release.

Also (0.1.1)

Windows notifications

notify now posts native toasts on Windows (notify::platform::SUPPORTED is true), through tauri-winrt-notification, the WinRT ToastNotificationManager wrapper that tauri-plugin-notification uses via notify-rust. The public API (notify::post, status, NotificationRequest, sound throttling, Do-Not-Disturb suppression) is unchanged, so callers are cross-platform.

  • Toasts are attributed to an AppUserModelID. An installed app owns one through its Start-menu shortcut; Tauri's NSIS and MSI installers set it to the bundle identifier, and the shell uses tauri.conf.json's identifier by default. Call notify::set_app_id("com.example.app") before the first notification to override it. The installer or shortcut must register that same AUMID, or Windows drops the toast.
  • An unpackaged dev build has no AUMID, so toasts fall back to PowerShell's (notify::FALLBACK_APP_ID) and show its name and icon.
  • sound_name accepts Default, IM, Mail, Reminder, SMS; any other name (for example the macOS Glass) plays the default toast sound.
  • Windows has no per-app permission prompt and this backend cannot read delivered toasts: authorization reports authorized, delivered is empty, and clear_delivered is a no-op.

Standalone open-URL hook

open_url delivers URLs the OS opens in the app, without the Shell plugin:

rightkit_tauri_shell::open_url::on_open_url(|urls| { /* mailto:, myapp://... */ });
let cold = rightkit_tauri_shell::open_url::current_urls(); // arrived before any handler existed
  • macOS: call open_url::handle_run_event(&event) from app.run(|_, event| ...) (Apple Event URLs, RunEvent::Opened).
  • Windows/Linux: open_url::init_single_instance(app_id, version) runs instance::arbitrate, captures this launch's command-line URLs and delivers a second launch's argv to the first instance (exit on Arbitration::Secondary). Without single instance, call open_url::capture_launch_args() once at startup.
  • on_open_url handlers receive URLs that arrive after registration, in registration order; current_urls() keeps the cold-start ones. The same URL within DEDUP_WINDOW (2 s) is delivered once. Only scheme:rest entries with a scheme of two or more characters count, so flags and C:\ paths are ignored.
  • The full Shell plugin feeds the same hub.
  • Scheme registration (mailto:, custom schemes) stays with the app's installer or bundle: CFBundleURLTypes on macOS, a protocol entry (NSIS/MSI or deep_link::register_scheme) on Windows, a .desktop MIME entry on Linux.

Second-instance callback, dependency ranges

Shell::builder(..).on_second_instance(|app, argv, cwd| ..) replaces the default second-launch behaviour (event plus reveal of the main window); the callback owns focus. Arbitration is unchanged, and URLs on the forwarded argv still reach open_url handlers and current_urls() before the callback runs. Without a callback nothing changes.

windows/webview2-com are feature-selected so they match the app's Tauri: wv2-0_38 (0.61/0.38, Tauri 2.11, default since 0.2.0, CHANGELOG.md) or wv2-0_39 (0.62/0.39, Tauri 2.12). A mismatch puts two copies beside Tauri's and cast() fails (E0599, found by MailRight). With default-features = false, add one of them, e.g. features = ["wv2-0_38"] (an earlier default, wv2-0_39, broke Tauri 2.11 apps; found by CodeRight).

Runtime budget

Generated from runtime-budget.json; edit that file, then run node packages/release/runtime-budgets.mjs sync <crate-dir>.

  • Resident memory ceiling: 119.0 MiB.
  • CPU ceiling (idle, steady state): 1%.
  • Wake-ups ceiling (whole process; installed and real measurements): 10.2 per second.
  • Wake-ups gate (synthetic bench): at most 4 per second over a bare runtime loop measured in the same bench run.
  • Disk operations ceiling: 1 operations per second.
  • Required scale: 1 watchdogs or more.
  • Required hosts: macOS only (Adrian 2026-10-14 local-build grant (.rightkit-local-build.json); Windows covered by the next CI run).
  • Status exposure: status() in src/watchdog.rs; exposes resident memory, idle CPU, wake-ups, disk operations.
  • Measured: win32/x64 (13th Gen Intel(R) Core(TM) i9-13900H), rightkit-tauri-shell 0.3.2 at d35c5785333d, 1 watchdogs (synthetic bench at the declared requiredScale (crates/rightkit-tauri-shell/benches/budget.rs): 30 s idle, Windows 11 dev profile), synthetic, rustright cargo test -p rightkit-tauri-shell --profile dev --bench budget --jobs 20 -- 30, 2026-10-18, evidence 61ea5d13ca34: resident memory 12.3 MiB, idle CPU 0.052%, wake-ups 6.862 per second, disk operations 0 operations per second.
  • Measured: darwin/arm64 (Mac18,5), rightkit-tauri-shell 0.3.2 at 8d96d8ca29df, 1 watchdogs (default hang watchdog on an idle Wry Tauri event loop with no windows; 5 s warm-up, 30 s idle), synthetic, rustright cargo test -p rightkit-tauri-shell --profile release-iterate --bench budget -- 30, 2026-10-18, evidence ea35573730c3: resident memory 64.6 MiB, idle CPU 0.053%, wake-ups 5.121 per second, disk operations n/a (not measurable on this host).
  • Measured: darwin/arm64 (Mac18,5), rightkit-tauri-shell 0.3.3 at 15b3ce83bcc1, 1 watchdogs (default hang watchdog on an idle Wry Tauri event loop with no windows; 5 s warm-up, 30 s idle), synthetic, rustright cargo test -p rightkit-tauri-shell --profile release-iterate --bench budget -- 30, 2026-10-18, evidence d44a1e2cb505: resident memory 64.6 MiB, idle CPU 0.022%, wake-ups 4.523 per second, disk operations n/a (not measurable on this host).
  • Measured: darwin/arm64 (Mac18,5), rightkit-tauri-shell 0.4.0 at db7de41ee2db, 1 watchdogs (3 interleaved pairs of a bare Wry loop and the default hang watchdog, 35 s each, median pair (#789)), synthetic, rustright cargo test -p rightkit-tauri-shell --profile release-iterate --bench budget -- 30, 2026-10-18, evidence df44e6df9b7f: resident memory 64.5 MiB, idle CPU 0.103%, wake-ups 10.481 per second, disk operations n/a (not measurable on this host); bare baseline wake-ups 9.489 per second, delta 0.992 per second.

Engine policy & independent trays (G15–G18)

Enable Cargo feature engine, then opt into native policy:

use std::time::Duration;
use rightkit_tauri_shell::engine::{EnginePolicy, EngineSpec, RestartPolicy};
let spec = EngineSpec::new(|| {
    let mut command = rightkit_child_process::OwnedCommand::new("my-engine");
    command.command_mut().arg("serve");
    command
});
let policy = EnginePolicy::default()
    .allow_methods(["doctor", "project.show", "render"])
    .method_timeout("render", Duration::from_secs(6 * 60 * 60))
    .restart_policy(RestartPolicy::default())
    .supervisor_config(|config| config.require_contract_version("1"));
let shell = rightkit_tauri_shell::Shell::builder("myapp")
    .engine_with_policy(spec, policy).build();

None allowlist permits every method; an empty allowlist denies every method. Names match exactly, before any client acquisition or spawn. Missing deadline entries use EngineSpec::call_timeout. Without supervision, None means no call deadline. Opting into restart_policy or supervisor_config uses rightkit-job-queue::EngineSupervisor; with call_timeout: None, its configured RPC timeout applies (120 seconds by default). supervisor_config runs after shell defaults & can set contract checks, readiness waits, stderr/protocol sinks, initial-failure behavior & frame limits. EnginePolicy::keep_alive_on_timeout = true abandons only caller wait; default supervised timeout kills wedged engine. Automatic, on-demand & disabled restarts share job-queue's bounded generation/budget rules. Failed in-flight calls are returned without replay. Engine wiring always takes a policy (engine_with_policy): a default EnginePolicy denies every method, so list them with allow_methods or opt in explicitly with allow_any_method().

Register commands through rightkit_tauri_shell::handler![] or add only commands::rk_shell_engine_call_tagged & commands::rk_shell_engine_status to an existing tauri::generate_handler!. Legacy rk_shell_engine_call keeps its signature & string errors; it applies configured allowlist/deadlines too. Tagged command adds optional tag & returns structured {code, message, origin} errors. Native callers use EngineHost::call_tagged(method, params, tag, Arc<dyn Fn(EngineEvent) + Send + Sync>).

EVENT_ENGINE / renderer EVENTS.engine carry {call, id, tag, event} for progress. Request ids originate in job-queue; caller tags are preserved independently for concurrent calls. $unsolicited frames have null id/tag. $lifecycle frames carry current lifecycle snapshot in event & raw transition diagnostics in supervisor; they have no request id. rk_shell_engine_status replays current state as {state, hello, restarts, last_error} in supervised mode (or null without an engine); legacy startup snapshots retain their old shape. States: starting, ready, restarting, failed, stopped; legacy startup failures use error. rk_shell_ready also replays state. Event forwarders stop during ordered shutdown.

Additional trays use independently owned handles; shell's primary tray stays available:

use std::sync::Arc;
use rightkit_tauri_shell::{MenuEntry, TraySpec};
use rightkit_tauri_shell::tray::{install_with_options, TrayOptions, TrayUpdate};
let demo = install_with_options(app, &TraySpec {
    tooltip: "Demo".into(),
    items: vec![MenuEntry::item("stop", "Stop demo")],
    ..Default::default()
}, "main", TrayOptions::custom_only("demo"), Some(Arc::new(|app, id| {
    // Invoke application action here; id is logical "stop".
})))?;
demo.update(app, &TrayUpdate { tooltip: Some("Recording".into()), ..Default::default() })?;
// Retain demo in application state for updates; demo.icon() exposes native title/icon APIs.

Call during setup/on main thread. custom_only uses exactly provided rows, including empty menus; TrayOptions::default() retains Show/custom/Quit order. Duplicate tray ids, empty or duplicate item ids & standard-action id collisions are rejected. Logical ids may repeat across trays: native ids are namespaced & handlers filter those exact ids, including after menu rebuilds. Custom-only menus may use shell Show/Quit logical ids as ordinary custom actions. Renderer tray events add trayId; native callback receives logical id. TrayHandle::update addresses only its own tray. Existing tray::install, tray::update, TraySpec & TrayUpdate remain unchanged.

Verification: first cargo build -p rightkit-tauri-shell --features engine --example shell_engine_fixture, then cargo test -p rightkit-tauri-shell --features engine --all-targets exercises real-child engine policy/integration tests; tests/tray_options.rs tests menu planning without native windows. Native multi-tray rendering/clicks require desktop smoke test.

Tagged errors identify origin as engine or supervisor. Retry readiness codes only for supervisor errors: an engine reply using down or not_ready may follow completed work.