Skip to main content

rightkit_tauri_shell/
devtools.rs

1//! DevTools policy: every Right app has no access to DevTools; ViewRight alone may inspect the
2//! HTML content it opens.
3//!
4//! The owner's fixed design, verbatim: every Right app has absolutely NO access to DevTools,
5//! blocked at each and every level that is possible, for the app, its shell and its own UI. The
6//! single exception is ViewRight: it opens HTML documents, and DevTools must be available INSIDE
7//! that opened HTML content only (the content frame), never for ViewRight's own UI or shell.
8//!
9//! Two values exist, [`DevToolsPolicy::Denied`] (the default) and [`DevToolsPolicy::ContentOnly`]
10//! (a named allowance an app must request explicitly). Under both, every webview the shell sees is
11//! made non-inspectable unless the app registered it as a content webview
12//! ([`ContentWebview`]) and the policy is `ContentOnly`.
13//!
14//! Layers (see README "DevTools policy" for what each can and cannot block):
15//! 1. [`guard_process_start`]: refuses a remote-debugging launch argument, removes the WebView2
16//!    environment overrides, and refuses a machine policy that injects debugging browser
17//!    arguments (Windows).
18//! 2. Native per-webview settings ([`apply_native`]): `isInspectable` / `developerExtrasEnabled`
19//!    (macOS), `AreDevToolsEnabled`, accelerator keys and the context-menu Inspect item (Windows).
20//! 3. The injected hardening script (keyboard shortcuts), in [`crate::harden`].
21//! 4. [`verify_not_remote_debuggable`]: a post-creation tripwire for a live remote-debugging port.
22//!
23//! Development builds are the one deliberate hole: with the off-by-default `dev-inspect` cargo
24//! feature compiled in, every webview is inspectable and the guards above stand down (see
25//! [`DEV_INSPECT_COMPILED`]). Such a build carries [`DEV_INSPECT_MARKER`], and `right-release`
26//! refuses to seal or upload a release or update build that contains it.
27//!
28//! Everything that decides (argument and environment scanning, registry output, content
29//! registration, capability checks) is a plain function so it is testable without a GUI.
30
31use std::collections::BTreeSet;
32use std::fmt;
33use std::path::PathBuf;
34use std::sync::OnceLock;
35use std::time::{Duration, SystemTime};
36
37use tauri::{AppHandle, Manager, Runtime, Webview};
38
39/// The owner's decision, stated verbatim. Do not edit or soften.
40pub const OWNER_DECISION: &str = "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.";
41
42/// Bytes a binary carries when it was built with the `content-devtools` feature. `right-release`
43/// accepts Tauri's devtools code in a binary only if this marker is present and the app is on the
44/// allow-list (`packages/release/devtools-policy.json`). Keep identical to
45/// `CONTENT_ONLY_MARKER` in `packages/release/devtools-policy.mjs`.
46pub const CONTENT_ONLY_MARKER: &str = "RIGHTKIT-SHELL-DEVTOOLS-CONTENT-ONLY-COMPILED";
47
48#[cfg(feature = "content-devtools")]
49#[used]
50static CONTENT_ONLY_MARKER_BYTES: &[u8] = b"RIGHTKIT-SHELL-DEVTOOLS-CONTENT-ONLY-COMPILED";
51
52/// Bytes that appear in every binary built with the `dev-inspect` feature, and in no other. `right-release`
53/// scans Mach-O, PE and ELF files for exactly this string and refuses to seal or upload a release or update
54/// tier build that has it. Keep identical to `DEV_INSPECT_MARKER` in
55/// `packages/release/qa-control-scan.mjs`. Code outside the `dev-inspect` feature must never reference this
56/// constant at run time (it would put the string into every binary that links the shell).
57pub const DEV_INSPECT_MARKER: &str = "RIGHTKIT-SHELL-DEV-INSPECT-COMPILED";
58
59/// True when this build was compiled with the `dev-inspect` feature: developer inspection is active.
60pub const DEV_INSPECT_COMPILED: bool = cfg!(feature = "dev-inspect");
61
62/// The marker as a retained static. `#[used]` keeps the object out of `-dead_strip` and `/OPT:REF`;
63/// [`keep_dev_inspect_marker`] also reads it from [`guard_process_start`], so the linker, LTO and the
64/// optimiser keep it for as long as the shell's startup code is kept.
65#[cfg(feature = "dev-inspect")]
66#[used]
67static DEV_INSPECT_MARKER_BYTES: [u8; DEV_INSPECT_MARKER.len()] = {
68    let src = DEV_INSPECT_MARKER.as_bytes();
69    let mut out = [0u8; DEV_INSPECT_MARKER.len()];
70    let mut i = 0;
71    while i < src.len() {
72        out[i] = src[i];
73        i += 1;
74    }
75    out
76};
77
78/// A volatile read of the marker's first byte, so the static is reachable from live startup code.
79#[cfg(feature = "dev-inspect")]
80#[inline(never)]
81fn keep_dev_inspect_marker() -> u8 {
82    // SAFETY: the pointer is the start of a live, initialised static array of non-zero length.
83    unsafe { std::ptr::read_volatile(DEV_INSPECT_MARKER_BYTES.as_ptr()) }
84}
85
86/// Who may use DevTools. Exactly two values.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88pub enum DevToolsPolicy {
89    /// No webview of the app is inspectable, in any build. The default for every app.
90    #[default]
91    Denied,
92    /// Only webviews the app registered as content webviews ([`ContentWebview`]) are inspectable.
93    /// A named allowance; intended for ViewRight only. Needs the `content-devtools` feature.
94    ContentOnly,
95}
96
97/// What the shell does to one webview.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum WebviewDecision {
100    /// Make the webview non-inspectable.
101    Deny,
102    /// Leave the webview inspectable (a registered content webview under `ContentOnly`).
103    AllowContent,
104    /// Developer inspection: the build was compiled with the `dev-inspect` feature, so every webview
105    /// (the app UI included) stays inspectable and no menu or shortcut is touched. Never produced
106    /// by a build without that feature.
107    AllowDev,
108}
109
110/// A webview that renders opened content and may be inspected under [`DevToolsPolicy::ContentOnly`].
111///
112/// Registration is refused unless the content webview cannot reach the app's IPC: no capability
113/// the app ships may apply to it, by webview label or by the label of its parent window (Tauri
114/// matches a capability's `windows` against the window a webview lives in, so a child webview of
115/// the main window inherits the main window's capability), and the app must declare an
116/// application ACL manifest (without one Tauri lets local-origin pages call every app command).
117#[derive(Debug, Clone)]
118pub struct ContentWebview {
119    /// Label of the content webview, or a prefix pattern such as `html-content-*` for webviews
120    /// created per document at run time.
121    pub label: String,
122    /// Label of the window that hosts it.
123    pub window_label: String,
124    /// Every capability document of the app, as JSON text (`include_str!` of each file under
125    /// `capabilities/`, plus any capability added at runtime).
126    pub capabilities: Vec<String>,
127    /// The app declares an ACL manifest for its own commands (`tauri_build::AppManifest` with
128    /// `commands(..)` in `build.rs`). The shell cannot read this at runtime, so the app attests it
129    /// and `right-release` checks `build.rs` for it.
130    pub app_acl_manifest: bool,
131}
132
133impl ContentWebview {
134    pub fn new(label: &str, window_label: &str) -> Self {
135        Self {
136            label: label.to_owned(),
137            window_label: window_label.to_owned(),
138            capabilities: Vec::new(),
139            app_acl_manifest: false,
140        }
141    }
142    /// Add one capability document (JSON text).
143    pub fn capability(mut self, json: &str) -> Self {
144        self.capabilities.push(json.to_owned());
145        self
146    }
147    /// Attest that the app declares an ACL manifest for its commands.
148    pub fn app_acl_manifest(mut self, declared: bool) -> Self {
149        self.app_acl_manifest = declared;
150        self
151    }
152}
153
154/// A refusal. Every variant is fatal at startup; there is no inspectable fallback.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum DevToolsError {
157    /// The process was launched with a remote-debugging or auto-open-DevTools argument.
158    RemoteDebuggingArgument { arg: String },
159    /// A machine or user policy injects a debugging browser argument into WebView2.
160    BrowserPolicyInjects { source: String, switch: String },
161    /// A machine or user policy makes WebView2 start a browser from another folder than the
162    /// installed runtime.
163    BrowserRuntimeRedirected { source: String, folder: String },
164    /// The Windows policy registry could not be read, so the policy cannot be ruled out.
165    RegistryUnreadable { detail: String },
166    /// A remote-debugging port file written by this process's WebView2 exists.
167    RemoteDebugPortLive { path: PathBuf },
168    /// Content webviews were registered but the policy is `Denied`.
169    NotContentOnly,
170    /// `ContentOnly` needs the `content-devtools` feature (it enables Tauri's devtools).
171    ContentDevtoolsNotCompiled,
172    /// The label is empty or not a valid Tauri label.
173    InvalidLabel { label: String },
174    /// The main (app UI) webview can never be a content webview.
175    MainWebview { label: String },
176    /// A capability applies to the content webview and grants permissions.
177    ContentHasIpc { label: String, capability: String },
178    /// A capability document could not be parsed.
179    CapabilitiesUnreadable { detail: String },
180    /// The app did not attest an application ACL manifest.
181    AppAclManifestMissing { label: String },
182    /// The webview is not a registered content webview.
183    NotAContentWebview { label: String },
184    /// The shell's DevTools gate is not installed.
185    NotInstalled,
186    /// The webview does not exist.
187    WebviewMissing { label: String },
188    /// DevTools could not be disabled in a denied webview, so the webview is refused.
189    NativePolicyNotApplied { label: String, detail: String },
190}
191
192impl DevToolsError {
193    pub fn code(&self) -> &'static str {
194        match self {
195            Self::RemoteDebuggingArgument { .. } => "remote_debugging_argument",
196            Self::BrowserPolicyInjects { .. } => "browser_policy_injects",
197            Self::BrowserRuntimeRedirected { .. } => "browser_runtime_redirected",
198            Self::RegistryUnreadable { .. } => "registry_unreadable",
199            Self::RemoteDebugPortLive { .. } => "remote_debug_port_live",
200            Self::NotContentOnly => "not_content_only",
201            Self::ContentDevtoolsNotCompiled => "content_devtools_not_compiled",
202            Self::InvalidLabel { .. } => "invalid_label",
203            Self::MainWebview { .. } => "main_webview",
204            Self::ContentHasIpc { .. } => "content_has_ipc",
205            Self::CapabilitiesUnreadable { .. } => "capabilities_unreadable",
206            Self::AppAclManifestMissing { .. } => "app_acl_manifest_missing",
207            Self::NotAContentWebview { .. } => "not_a_content_webview",
208            Self::NotInstalled => "not_installed",
209            Self::WebviewMissing { .. } => "webview_missing",
210            Self::NativePolicyNotApplied { .. } => "native_policy_not_applied",
211        }
212    }
213}
214
215impl fmt::Display for DevToolsError {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        write!(f, "rightkit-tauri-shell devtools policy: ")?;
218        match self {
219            Self::RemoteDebuggingArgument { arg } => write!(f, "refusing to start, the process was launched with a debugging argument ({arg})"),
220            Self::BrowserPolicyInjects { source, switch } => write!(f, "refusing to create a webview, {source} injects a debugging browser argument ({switch}); an application cannot override a machine policy"),
221            Self::BrowserRuntimeRedirected { source, folder } => write!(f, "refusing to create a webview, {source} makes WebView2 start a browser from `{folder}` instead of the installed runtime; an application cannot override a machine policy"),
222            Self::RegistryUnreadable { detail } => write!(f, "refusing to create a webview, the WebView2 policy registry could not be read ({detail})"),
223            Self::RemoteDebugPortLive { path } => write!(f, "refusing to run, a remote-debugging port is live ({})", path.display()),
224            Self::NotContentOnly => write!(f, "content webviews are registered but the policy is Denied; select DevToolsPolicy::ContentOnly explicitly"),
225            Self::ContentDevtoolsNotCompiled => write!(f, "ContentOnly needs the rightkit-tauri-shell `content-devtools` feature"),
226            Self::InvalidLabel { label } => write!(f, "`{label}` is not a valid webview label"),
227            Self::MainWebview { label } => write!(f, "`{label}` is the app UI webview; it can never be a content webview"),
228            Self::ContentHasIpc { label, capability } => write!(f, "content webview `{label}` is covered by capability `{capability}`, which grants app IPC; content webviews need no capability at all"),
229            Self::CapabilitiesUnreadable { detail } => write!(f, "a capability document could not be read ({detail})"),
230            Self::AppAclManifestMissing { label } => write!(f, "content webview `{label}` requires an application ACL manifest for the app's own commands (otherwise local content can call every app command)"),
231            Self::NotAContentWebview { label } => write!(f, "`{label}` is not a registered content webview"),
232            Self::NotInstalled => write!(f, "the shell is not installed"),
233            Self::WebviewMissing { label } => write!(f, "webview `{label}` does not exist"),
234            Self::NativePolicyNotApplied { label, detail } => write!(f, "refusing webview `{label}`, DevTools could not be disabled in it ({detail})"),
235        }
236    }
237}
238
239impl std::error::Error for DevToolsError {}
240
241impl From<DevToolsError> for crate::ShellError {
242    fn from(e: DevToolsError) -> Self {
243        crate::ShellError::PermissionDenied(e.to_string())
244    }
245}
246
247// ---------------------------------------------------------------------------------------------
248// Names. They are stored reversed and reassembled at run time: `right-release` scans every
249// shipped binary for the remote-debugging switch and the WebView2 override variable as evidence of
250// a debugging surface, and this crate must not trip its own scan.
251// ---------------------------------------------------------------------------------------------
252
253fn unrev(s: &str) -> String {
254    s.chars().rev().collect()
255}
256
257/// Lower-case switch names (no leading dashes) that must never reach a browser process.
258fn forbidden_switch_names() -> Vec<String> {
259    [
260        "trop-gniggubed-etomer",
261        "epip-gniggubed-etomer",
262        "sserdda-gniggubed-etomer",
263        "snigiro-wolla-etomer",
264        "sbat-rof-slootved-nepo-otua",
265        "gniggubedetomerpdwslootvedegdesm",
266    ]
267    .iter()
268    .map(|s| unrev(s))
269    .collect()
270}
271
272/// Environment variables that can alter or attach a debugger to WebView2.
273fn scrubbed_env_names() -> Vec<String> {
274    [
275        "STNEMUGRA_RESWORB_LANOITIDDA_2WEIVBEW",
276        "REGGUBED_TPIRCS_ROF_EPIP_2WEIVBEW",
277    ]
278    .iter()
279    .map(|s| unrev(s))
280    .collect()
281}
282
283/// The debugging switches found in `args` (a command line or an `AdditionalBrowserArguments`
284/// string): remote debugging port, pipe, address and allowed origins, auto-open DevTools, the
285/// Edge remote-debugging flag, and `--js-flags` that enable the V8 inspector.
286pub fn forbidden_switches_in(args: &str) -> Vec<String> {
287    let names = forbidden_switch_names();
288    let mut out = Vec::new();
289    for raw in args.split_whitespace() {
290        let token = raw.trim_matches(|c| c == '"' || c == '\'');
291        let Some(rest) = token.strip_prefix('-') else {
292            continue;
293        };
294        let rest = rest.trim_start_matches('-');
295        let (name, value) = rest.split_once('=').unwrap_or((rest, ""));
296        let name = name.to_ascii_lowercase();
297        let hit = names.contains(&name)
298            || (name == "js-flags" && value.to_ascii_lowercase().contains("inspect"));
299        if hit {
300            out.push(token.to_owned());
301        }
302    }
303    out
304}
305
306/// Launch arguments that carry a debugging switch.
307pub fn argv_violations(args: &[String]) -> Vec<String> {
308    args.iter().flat_map(|a| forbidden_switches_in(a)).collect()
309}
310
311/// Names (as spelled in `names`) of the environment variables to remove from the process.
312/// Matching ignores ASCII case: Windows environment names are case-insensitive.
313pub fn env_vars_to_scrub<I, S>(names: I) -> Vec<String>
314where
315    I: IntoIterator<Item = S>,
316    S: AsRef<str>,
317{
318    let wanted = scrubbed_env_names();
319    names
320        .into_iter()
321        .filter(|n| wanted.iter().any(|w| w.eq_ignore_ascii_case(n.as_ref())))
322        .map(|n| n.as_ref().to_owned())
323        .collect()
324}
325
326/// One string value of a registry key.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub struct RegValue {
329    pub name: String,
330    pub value: String,
331}
332
333/// `REG_SZ`, `REG_EXPAND_SZ` and `REG_MULTI_SZ`: the registry types that hold text.
334const REG_TEXT_TYPES: [u32; 3] = [1, 2, 7];
335
336/// The text of a registry value from its type and raw bytes (UTF-16LE), or `None` for a type that
337/// holds no text (a number or binary data cannot carry a browser argument). The strings of a
338/// `REG_MULTI_SZ` are joined with spaces; the terminating NULs are dropped.
339pub fn decode_reg_text(kind: u32, data: &[u8]) -> Option<String> {
340    if !REG_TEXT_TYPES.contains(&kind) {
341        return None;
342    }
343    let units: Vec<u16> = data
344        .as_chunks::<2>()
345        .0
346        .iter()
347        .map(|pair| u16::from_le_bytes(*pair))
348        .collect();
349    let text = String::from_utf16_lossy(&units);
350    Some(
351        text.split('\0')
352            .filter(|part| !part.is_empty())
353            .collect::<Vec<_>>()
354            .join(" "),
355    )
356}
357
358/// What opening a policy key means for the guard, from the status `RegOpenKeyExW` returned.
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub enum PolicyKeyOpen {
361    /// The key is open and its values are to be read.
362    Opened,
363    /// `ERROR_FILE_NOT_FOUND`: the key does not exist, so there is no policy. The only status
364    /// that means that.
365    Absent,
366    /// Anything else (access denied, a registry failure): what the policy says is unknown, and
367    /// the guard refuses to start.
368    Unreadable(u32),
369}
370
371impl PolicyKeyOpen {
372    pub fn from_status(status: u32) -> Self {
373        match status {
374            0 => Self::Opened,
375            2 => Self::Absent,
376            other => Self::Unreadable(other),
377        }
378    }
379}
380
381/// Errors for every value of a policy key that injects a debugging switch. A value named for a
382/// different application is still refused: an administrator-set debugging policy is never normal.
383pub fn policy_violations(values: &[RegValue], source: &str) -> Vec<DevToolsError> {
384    values
385        .iter()
386        .flat_map(|v| {
387            forbidden_switches_in(&v.value)
388                .into_iter()
389                .map(move |switch| DevToolsError::BrowserPolicyInjects {
390                    source: format!("{source} (value `{}`)", v.name),
391                    switch,
392                })
393        })
394        .collect()
395}
396
397/// Whether a WebView2 policy value named `name` applies to the program whose executable file is
398/// `exe_name`: the name is `*` (every application) or this program's executable file name,
399/// compared without regard to case. Every other name belongs to another program. When this
400/// program's own file name is unknown, every value is taken to apply.
401pub fn policy_value_applies(name: &str, exe_name: &str) -> bool {
402    let name = name.trim();
403    exe_name.is_empty() || name == "*" || name.eq_ignore_ascii_case(exe_name)
404}
405
406/// The name of the environment variable that makes WebView2 start a browser from another folder.
407pub fn runtime_folder_env_name() -> String {
408    unrev("REDLOF_ELBATUCEXE_RESWORB_2WEIVBEW")
409}
410
411/// The refusal for that variable's `value` in the process environment at start, if it is set to
412/// anything. A launcher controls the environment as it controls policy, so it is refused the
413/// same way.
414pub fn runtime_folder_env_violation(value: Option<&str>) -> Option<DevToolsError> {
415    let folder = value?.trim();
416    if folder.is_empty() {
417        return None;
418    }
419    Some(DevToolsError::BrowserRuntimeRedirected {
420        source: format!("the environment variable {}", runtime_folder_env_name()),
421        folder: folder.to_owned(),
422    })
423}
424
425/// Errors for every value of the WebView2 `BrowserExecutableFolder` policy that applies to this
426/// program. Such a value makes WebView2 start whatever browser is in that folder, with every
427/// argument and behaviour that browser was built with, so nothing this crate sets can be relied on.
428pub fn runtime_folder_violations(
429    values: &[RegValue],
430    source: &str,
431    exe_name: &str,
432) -> Vec<DevToolsError> {
433    values
434        .iter()
435        .filter(|v| !v.value.trim().is_empty() && policy_value_applies(&v.name, exe_name))
436        .map(|v| DevToolsError::BrowserRuntimeRedirected {
437            source: format!("{source} (value `{}`)", v.name),
438            folder: v.value.trim().to_owned(),
439        })
440        .collect()
441}
442
443/// `text` with every `%NAME%` that `env` knows replaced by its value. An unknown name and a lone
444/// `%` stay as they are.
445pub fn expand_env_refs(text: &str, env: impl Fn(&str) -> Option<String>) -> String {
446    let mut out = String::new();
447    let mut rest = text;
448    while let Some(open) = rest.find('%') {
449        out.push_str(&rest[..open]);
450        let after = &rest[open + 1..];
451        match after.find('%') {
452            Some(close) if close > 0 => match env(&after[..close]) {
453                Some(value) => {
454                    out.push_str(&value);
455                    rest = &after[close + 1..];
456                }
457                None => {
458                    out.push('%');
459                    rest = after;
460                }
461            },
462            _ => {
463                out.push('%');
464                rest = after;
465            }
466        }
467    }
468    out.push_str(rest);
469    out
470}
471
472/// The user data folders the WebView2 `UserDataFolder` policy names for this program, with
473/// environment references expanded. The browser writes its port file there, not in the folder
474/// the application chose, so the tripwire has to look there too.
475pub fn policy_user_data_dirs(
476    values: &[RegValue],
477    exe_name: &str,
478    env: impl Fn(&str) -> Option<String>,
479) -> Vec<PathBuf> {
480    values
481        .iter()
482        .filter(|v| !v.value.trim().is_empty() && policy_value_applies(&v.name, exe_name))
483        .map(|v| PathBuf::from(expand_env_refs(v.value.trim(), &env)))
484        .collect()
485}
486
487/// Further folders the port-file tripwire looks in, besides the app's local and roaming data.
488static EXTRA_USER_DATA_DIRS: std::sync::Mutex<Vec<PathBuf>> = std::sync::Mutex::new(Vec::new());
489
490/// Tell the tripwire about a user data folder the application gives a webview itself (Tauri's
491/// `data_directory`). Call it before the webview is created. The folders a policy or the WebView2
492/// user-data environment variable names are added by [`guard_process_start`].
493pub fn register_user_data_dir(dir: impl Into<PathBuf>) {
494    let dir = dir.into();
495    let mut dirs = EXTRA_USER_DATA_DIRS
496        .lock()
497        .unwrap_or_else(|poison| poison.into_inner());
498    if !dirs.contains(&dir) {
499        dirs.push(dir);
500    }
501}
502
503/// The result of [`guard_process_start`].
504#[derive(Debug, Clone, PartialEq, Eq, Default)]
505pub struct GuardReport {
506    /// Environment variables removed from the process.
507    pub scrubbed_env: Vec<String>,
508}
509
510static GUARD: OnceLock<Result<GuardReport, DevToolsError>> = OnceLock::new();
511static PROCESS_START: OnceLock<SystemTime> = OnceLock::new();
512
513/// Process-start guard. Call it first in `main` (before Tauri builds anything) to fail before any
514/// webview exists; the shell plugin also runs it when it is constructed and refuses to set up when
515/// it failed. Idempotent: the first result is returned on every call.
516///
517/// 1. Refuses a launch argument that enables remote debugging or auto-opens DevTools.
518/// 2. Removes the WebView2 override environment variables from this process, so they cannot reach
519///    the browser process that WebView2 starts later (Windows).
520/// 3. Refuses to continue when the WebView2 `AdditionalBrowserArguments` policy (HKLM, HKCU,
521///    WOW6432Node) injects a debugging switch. WebView2 appends policy arguments to whatever the
522///    application passes, so the application cannot override them (Windows).
523/// 4. Refuses to continue when the WebView2 `BrowserExecutableFolder` policy, or the environment
524///    variable of the same purpose, makes WebView2 start a browser from another folder for this
525///    program (Windows).
526/// 5. Records the folders the `UserDataFolder` policy and the WebView2 user-data environment
527///    variable name, so the port-file tripwire looks there too (Windows).
528///
529/// A build compiled with the `dev-inspect` feature does none of this: it prints one loud stderr
530/// line saying developer inspection is active and returns an empty report.
531pub fn guard_process_start() -> Result<GuardReport, DevToolsError> {
532    GUARD.get_or_init(run_guard).clone()
533}
534
535fn run_guard() -> Result<GuardReport, DevToolsError> {
536    let _ = PROCESS_START.set(SystemTime::now());
537    if DEV_INSPECT_COMPILED {
538        return Ok(announce_dev_inspect());
539    }
540    let argv: Vec<String> = std::env::args_os()
541        .skip(1)
542        .map(|a| a.to_string_lossy().into_owned())
543        .collect();
544    if let Some(arg) = argv_violations(&argv).into_iter().next() {
545        return Err(DevToolsError::RemoteDebuggingArgument { arg });
546    }
547    let present: Vec<String> = std::env::vars_os()
548        .map(|(k, _)| k.to_string_lossy().into_owned())
549        .collect();
550    let scrubbed_env = env_vars_to_scrub(present.iter());
551    for name in &scrubbed_env {
552        std::env::remove_var(name);
553    }
554    #[cfg(windows)]
555    {
556        if let Some(err) = registry_policy_violations()?.into_iter().next() {
557            return Err(err);
558        }
559        let folder = std::env::var_os(runtime_folder_env_name());
560        let folder = folder.as_ref().map(|value| value.to_string_lossy());
561        if let Some(err) = runtime_folder_env_violation(folder.as_deref()) {
562            return Err(err);
563        }
564        let exe = current_exe_name();
565        for (source, values) in read_policy("BrowserExecutableFolder")? {
566            if let Some(err) = runtime_folder_violations(&values, source, &exe)
567                .into_iter()
568                .next()
569            {
570                return Err(err);
571            }
572        }
573        let env = |name: &str| std::env::var(name).ok();
574        for (_, values) in read_policy("UserDataFolder")? {
575            for dir in policy_user_data_dirs(&values, &exe, env) {
576                register_user_data_dir(dir);
577            }
578        }
579        // The variable is left in place (a test harness may set it to keep runs apart); the
580        // folder it names is watched like the others.
581        if let Some(dir) = std::env::var_os(unrev("REDLOF_ATAD_RESU_2WEIVBEW")) {
582            if !dir.is_empty() {
583                register_user_data_dir(PathBuf::from(dir));
584            }
585        }
586    }
587    Ok(GuardReport { scrubbed_env })
588}
589
590/// The one loud line of a `dev-inspect` build. It also reads the marker static from live startup
591/// code, so the marker survives dead-code elimination for as long as the shell does.
592#[cfg(feature = "dev-inspect")]
593fn announce_dev_inspect() -> GuardReport {
594    let kept = keep_dev_inspect_marker();
595    eprintln!(
596        "RIGHTKIT SHELL DEVELOPER INSPECTION ACTIVE: this build was compiled with the rightkit-tauri-shell `dev-inspect` \
597         feature (marker byte {kept:#04x}). Every webview is inspectable, debugging switches and WebView2 \
598         environment overrides are not scrubbed, and no remote-debugging refusal or port tripwire runs. \
599         A build like this can never be released; right-release refuses it."
600    );
601    GuardReport::default()
602}
603
604/// Never called: `DEV_INSPECT_COMPILED` is false here.
605#[cfg(not(feature = "dev-inspect"))]
606fn announce_dev_inspect() -> GuardReport {
607    GuardReport::default()
608}
609
610/// Windows: the registry, read through the registry API. No `reg.exe`: a program started by name
611/// is found in the application's directory and the working directory before the system's, and its
612/// exit code cannot tell a missing key from one that could not be read.
613#[cfg(windows)]
614mod registry {
615    use super::{decode_reg_text, PolicyKeyOpen, RegValue};
616
617    pub(super) const HKEY_CURRENT_USER: isize = 0x8000_0001_u32 as i32 as isize;
618    pub(super) const HKEY_LOCAL_MACHINE: isize = 0x8000_0002_u32 as i32 as isize;
619    /// The 64-bit and the 32-bit registry view, named explicitly so a 32-bit and a 64-bit build
620    /// read the same two keys.
621    pub(super) const VIEW_64: u32 = 0x0100;
622    pub(super) const VIEW_32: u32 = 0x0200;
623    const KEY_READ: u32 = 0x0002_0019;
624    const ERROR_MORE_DATA: u32 = 234;
625    const ERROR_NO_MORE_ITEMS: u32 = 259;
626    /// The longest value name the registry allows, in UTF-16 units, plus the terminator.
627    const NAME_UNITS: usize = 16_384;
628
629    #[link(name = "advapi32")]
630    unsafe extern "system" {
631        fn RegOpenKeyExW(
632            key: isize,
633            sub_key: *const u16,
634            options: u32,
635            access: u32,
636            result: *mut isize,
637        ) -> u32;
638        fn RegEnumValueW(
639            key: isize,
640            index: u32,
641            name: *mut u16,
642            name_len: *mut u32,
643            reserved: *mut u32,
644            kind: *mut u32,
645            data: *mut u8,
646            data_len: *mut u32,
647        ) -> u32;
648        fn RegCloseKey(key: isize) -> u32;
649    }
650
651    struct Key(isize);
652
653    impl Drop for Key {
654        fn drop(&mut self) {
655            // SAFETY: the handle came from RegOpenKeyExW and is closed once.
656            unsafe { RegCloseKey(self.0) };
657        }
658    }
659
660    /// The text values of `root\path` in `view`. `Ok(None)`: the key does not exist. `Err`: the
661    /// key could not be opened or read; the text names the step and the Win32 status.
662    pub(super) fn text_values(
663        root: isize,
664        path: &str,
665        view: u32,
666    ) -> Result<Option<Vec<RegValue>>, String> {
667        let wide: Vec<u16> = path.encode_utf16().chain([0]).collect();
668        let mut handle: isize = 0;
669        // SAFETY: `wide` is NUL-terminated and outlives the call; `handle` is a valid
670        // out-parameter.
671        let status = unsafe { RegOpenKeyExW(root, wide.as_ptr(), 0, KEY_READ | view, &mut handle) };
672        match PolicyKeyOpen::from_status(status) {
673            PolicyKeyOpen::Opened => {}
674            PolicyKeyOpen::Absent => return Ok(None),
675            PolicyKeyOpen::Unreadable(code) => {
676                return Err(format!("open failed with Win32 error {code}"))
677            }
678        }
679        let key = Key(handle);
680        let mut values = Vec::new();
681        let mut name = vec![0u16; NAME_UNITS];
682        let mut data = vec![0u8; 4096];
683        let mut index = 0u32;
684        loop {
685            let mut name_len = name.len() as u32;
686            let mut data_len = data.len() as u32;
687            let mut kind = 0u32;
688            // SAFETY: both buffers are writable for the lengths passed, which the call updates;
689            // the reserved pointer is null as documented.
690            let status = unsafe {
691                RegEnumValueW(
692                    key.0,
693                    index,
694                    name.as_mut_ptr(),
695                    &mut name_len,
696                    std::ptr::null_mut(),
697                    &mut kind,
698                    data.as_mut_ptr(),
699                    &mut data_len,
700                )
701            };
702            match status {
703                0 => {
704                    if let Some(value) = decode_reg_text(kind, &data[..data_len as usize]) {
705                        values.push(RegValue {
706                            name: String::from_utf16_lossy(&name[..name_len as usize]),
707                            value,
708                        });
709                    }
710                    index += 1;
711                }
712                // The data did not fit: `data_len` now holds the size needed. Same index again.
713                ERROR_MORE_DATA => {
714                    let needed = (data_len as usize).max(data.len() * 2);
715                    data.resize(needed, 0);
716                }
717                ERROR_NO_MORE_ITEMS => return Ok(Some(values)),
718                other => {
719                    return Err(format!(
720                        "reading value {index} failed with Win32 error {other}"
721                    ))
722                }
723            }
724        }
725    }
726}
727
728/// Every debugging switch the WebView2 `AdditionalBrowserArguments` policy injects, from the
729/// machine policy in both registry views and the user policy. A key that does not exist is no
730/// policy; a key that cannot be opened or read is [`DevToolsError::RegistryUnreadable`], and the
731/// guard refuses to start: what an unreadable policy says is not known, so it is not assumed empty.
732#[cfg(windows)]
733fn registry_policy_violations() -> Result<Vec<DevToolsError>, DevToolsError> {
734    let mut out = Vec::new();
735    for (source, values) in read_policy("AdditionalBrowserArguments")? {
736        out.extend(policy_violations(&values, source));
737    }
738    Ok(out)
739}
740
741/// This program's executable file name, or empty when it cannot be determined.
742#[cfg(windows)]
743fn current_exe_name() -> String {
744    std::env::current_exe()
745        .ok()
746        .and_then(|path| {
747            path.file_name()
748                .map(|name| name.to_string_lossy().into_owned())
749        })
750        .unwrap_or_default()
751}
752
753/// The text values of one WebView2 policy, from the machine policy in both registry views and the
754/// user policy, each with the name of where it was read. A key that does not exist contributes
755/// nothing; a key that cannot be opened or read is [`DevToolsError::RegistryUnreadable`].
756#[cfg(windows)]
757fn read_policy(policy: &str) -> Result<Vec<(&'static str, Vec<RegValue>)>, DevToolsError> {
758    let path = format!(r"Software\Policies\Microsoft\Edge\WebView2\{policy}");
759    let path = path.as_str();
760    let keys = [
761        (
762            "HKLM policy (64-bit view)",
763            registry::HKEY_LOCAL_MACHINE,
764            registry::VIEW_64,
765        ),
766        (
767            "HKLM policy (32-bit view)",
768            registry::HKEY_LOCAL_MACHINE,
769            registry::VIEW_32,
770        ),
771        (
772            "HKCU policy",
773            registry::HKEY_CURRENT_USER,
774            registry::VIEW_64,
775        ),
776    ];
777    let mut out = Vec::new();
778    for (source, root, view) in keys {
779        match registry::text_values(root, path, view) {
780            Ok(None) => {}
781            Ok(Some(values)) => out.push((source, values)),
782            Err(detail) => {
783                return Err(DevToolsError::RegistryUnreadable {
784                    detail: format!("{source}, {path}: {detail}"),
785                })
786            }
787        }
788    }
789    Ok(out)
790}
791
792/// Test support (Windows): read the text values of a key under `HKEY_CURRENT_USER` the way the
793/// guard reads the policy keys. `Ok(None)` when the key does not exist.
794#[cfg(windows)]
795#[doc(hidden)]
796pub fn read_user_key_text_values(path: &str) -> Result<Option<Vec<RegValue>>, String> {
797    registry::text_values(registry::HKEY_CURRENT_USER, path, registry::VIEW_64)
798}
799
800/// The file name Chromium writes into the user data folder when remote debugging is on.
801pub const DEBUG_PORT_FILE: &str = "DevToolsActivePort";
802
803/// A port file older than the process (by more than 2 s) is a leftover of an earlier run.
804pub fn is_live_debug_port_file(modified: SystemTime, process_start: SystemTime) -> bool {
805    modified + Duration::from_secs(2) >= process_start
806}
807
808/// A live remote-debugging port file under any of `dirs` (or under `<dir>/EBWebView`).
809pub fn live_debug_port_file(dirs: &[PathBuf], process_start: SystemTime) -> Option<PathBuf> {
810    for dir in dirs {
811        for candidate in [
812            dir.join(DEBUG_PORT_FILE),
813            dir.join("EBWebView").join(DEBUG_PORT_FILE),
814        ] {
815            if let Ok(meta) = std::fs::metadata(&candidate) {
816                // Unreadable time: assume live (fail closed).
817                let modified = meta.modified().unwrap_or_else(|_| SystemTime::now());
818                if is_live_debug_port_file(modified, process_start) {
819                    return Some(candidate);
820                }
821            }
822        }
823    }
824    None
825}
826
827/// Post-creation tripwire (Windows): the browser process writes the port file while it starts, so a
828/// fresh file next to this process's user data means a debugging port is open. A no-op elsewhere.
829pub fn verify_not_remote_debuggable<R: Runtime>(app: &AppHandle<R>) -> Result<(), DevToolsError> {
830    // A `dev-inspect` build is meant to be attached to: no tripwire.
831    if DEV_INSPECT_COMPILED {
832        return Ok(());
833    }
834    #[cfg(windows)]
835    {
836        let start = PROCESS_START.get().copied().unwrap_or_else(SystemTime::now);
837        let mut dirs: Vec<PathBuf> = Vec::new();
838        if let Ok(d) = app.path().app_local_data_dir() {
839            dirs.push(d);
840        }
841        if let Ok(d) = app.path().app_data_dir() {
842            dirs.push(d);
843        }
844        dirs.extend(
845            EXTRA_USER_DATA_DIRS
846                .lock()
847                .unwrap_or_else(|poison| poison.into_inner())
848                .iter()
849                .cloned(),
850        );
851        if let Some(path) = live_debug_port_file(&dirs, start) {
852            return Err(DevToolsError::RemoteDebugPortLive { path });
853        }
854    }
855    #[cfg(not(windows))]
856    let _ = app;
857    Ok(())
858}
859
860/// The tripwire once more, the first time a page has finished loading; later calls return `Ok`
861/// without looking. The browser process can write its port file after the webview is ready, which
862/// is when [`verify_not_remote_debuggable`] first runs.
863pub fn verify_after_first_navigation<R: Runtime>(app: &AppHandle<R>) -> Result<(), DevToolsError> {
864    static DONE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
865    if DONE.swap(true, std::sync::atomic::Ordering::SeqCst) {
866        return Ok(());
867    }
868    verify_not_remote_debuggable(app)
869}
870
871// ---------------------------------------------------------------------------------------------
872// Registration and the gate
873// ---------------------------------------------------------------------------------------------
874
875/// Which webviews are inspectable. Built once at startup and kept in Tauri state.
876#[derive(Debug, Clone, PartialEq, Eq)]
877pub struct DevToolsGate {
878    policy: DevToolsPolicy,
879    main_label: String,
880    content: BTreeSet<String>,
881    /// Built with the `dev-inspect` feature: every webview is inspectable.
882    dev_inspect: bool,
883}
884
885impl DevToolsGate {
886    /// The gate that denies every webview.
887    pub fn denied(main_label: &str) -> Self {
888        Self {
889            policy: DevToolsPolicy::Denied,
890            main_label: main_label.to_owned(),
891            content: BTreeSet::new(),
892            dev_inspect: false,
893        }
894    }
895    /// Whether this is the gate of a `dev-inspect` build (every webview inspectable). Only
896    /// [`build_gate_with`] with `dev_inspect = true` produces one.
897    pub fn is_dev_inspect(&self) -> bool {
898        self.dev_inspect
899    }
900    pub fn policy(&self) -> DevToolsPolicy {
901        self.policy
902    }
903    /// Registered content webview labels or prefix patterns (empty under `Denied`). A `dev-inspect`
904    /// gate returns the single pattern `*`, so the injected script stands aside in every webview.
905    pub fn content_labels(&self) -> Vec<String> {
906        if self.dev_inspect {
907            return vec!["*".to_owned()];
908        }
909        self.content.iter().cloned().collect()
910    }
911    /// What to do with the webview labelled `label`. Anything not registered is denied, and the
912    /// main webview is denied whatever else is true.
913    pub fn decide(&self, label: &str) -> WebviewDecision {
914        if self.dev_inspect {
915            return WebviewDecision::AllowDev;
916        }
917        if self.policy == DevToolsPolicy::ContentOnly
918            && label != self.main_label
919            && self
920                .content
921                .iter()
922                .any(|pattern| glob_match(pattern, label))
923        {
924            WebviewDecision::AllowContent
925        } else {
926            WebviewDecision::Deny
927        }
928    }
929}
930
931fn valid_label(label: &str) -> bool {
932    !label.is_empty()
933        && label
934            .chars()
935            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '/' | ':' | '_'))
936}
937
938/// A content label is an exact Tauri label, or a prefix pattern: a valid label followed by one
939/// trailing `*` (`html-content-*`), for webviews created per document or tab at run time.
940fn valid_content_label(label: &str) -> bool {
941    match label.strip_suffix('*') {
942        Some(prefix) => valid_label(prefix),
943        None => valid_label(label),
944    }
945}
946
947/// Could any label match both patterns? Conservative: patterns are compared by their literal head
948/// (the text before the first wildcard), and heads that are prefixes of one another overlap.
949pub fn patterns_may_overlap(a: &str, b: &str) -> bool {
950    let head = |p: &str| match p.find(['*', '?']) {
951        Some(i) => (p[..i].to_owned(), true),
952        None => (p.to_owned(), false),
953    };
954    let (ha, wa) = head(a);
955    let (hb, wb) = head(b);
956    match (wa, wb) {
957        (false, false) => ha == hb,
958        (true, false) => hb.starts_with(&ha) && glob_match(a, b),
959        (false, true) => ha.starts_with(&hb) && glob_match(b, a),
960        (true, true) => ha.starts_with(&hb) || hb.starts_with(&ha),
961    }
962}
963
964/// `*` matches any run, `?` one character: the glob subset Tauri capability patterns use.
965pub fn glob_match(pattern: &str, text: &str) -> bool {
966    let p: Vec<char> = pattern.chars().collect();
967    let t: Vec<char> = text.chars().collect();
968    let (mut pi, mut ti) = (0usize, 0usize);
969    let (mut star, mut mark) = (None::<usize>, 0usize);
970    while ti < t.len() {
971        if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
972            pi += 1;
973            ti += 1;
974        } else if pi < p.len() && p[pi] == '*' {
975            star = Some(pi);
976            mark = ti;
977            pi += 1;
978        } else if let Some(s) = star {
979            pi = s + 1;
980            mark += 1;
981            ti = mark;
982        } else {
983            return false;
984        }
985    }
986    while pi < p.len() && p[pi] == '*' {
987        pi += 1;
988    }
989    pi == p.len()
990}
991
992fn patterns(value: &serde_json::Value, key: &str) -> Vec<String> {
993    value
994        .get(key)
995        .and_then(|v| v.as_array())
996        .map(|a| {
997            a.iter()
998                .filter_map(|s| s.as_str().map(str::to_owned))
999                .collect()
1000        })
1001        .unwrap_or_default()
1002}
1003
1004/// The identifier of the first capability in `doc` that applies to the webview and grants
1005/// permissions. A capability applies when one of its `webviews` patterns matches the webview label
1006/// or one of its `windows` patterns matches the label of the window that hosts the webview.
1007/// `webview_label` may be a content prefix pattern (`html-content-*`); overlap is judged conservatively.
1008pub fn capability_granting_ipc(
1009    doc: &str,
1010    webview_label: &str,
1011    window_label: &str,
1012) -> Result<Option<String>, DevToolsError> {
1013    let parsed: serde_json::Value =
1014        serde_json::from_str(doc).map_err(|e| DevToolsError::CapabilitiesUnreadable {
1015            detail: e.to_string(),
1016        })?;
1017    let docs: Vec<&serde_json::Value> = match &parsed {
1018        serde_json::Value::Array(items) => items.iter().collect(),
1019        other => vec![other],
1020    };
1021    for cap in docs {
1022        let applies = patterns(cap, "webviews")
1023            .iter()
1024            .any(|p| patterns_may_overlap(p, webview_label))
1025            || patterns(cap, "windows")
1026                .iter()
1027                .any(|p| glob_match(p, window_label));
1028        let grants = cap
1029            .get("permissions")
1030            .and_then(|v| v.as_array())
1031            .is_some_and(|a| !a.is_empty());
1032        if applies && grants {
1033            let id = cap
1034                .get("identifier")
1035                .and_then(|v| v.as_str())
1036                .unwrap_or("<unnamed>");
1037            return Ok(Some(id.to_owned()));
1038        }
1039    }
1040    Ok(None)
1041}
1042
1043/// Validate the policy and the content registrations and build the gate. `devtools_compiled` is
1044/// `cfg!(feature = "content-devtools")` in the shell; it is a parameter so tests can cover both.
1045pub fn build_gate(
1046    policy: DevToolsPolicy,
1047    main_label: &str,
1048    registrations: &[ContentWebview],
1049    devtools_compiled: bool,
1050) -> Result<DevToolsGate, DevToolsError> {
1051    build_gate_with(policy, main_label, registrations, devtools_compiled, false)
1052}
1053
1054/// [`build_gate`] plus the `dev-inspect` switch (`DEV_INSPECT_COMPILED` in the shell). With
1055/// `dev_inspect` the registrations are still validated as before (a misconfiguration surfaces in
1056/// development, not only in the release build), Tauri's devtools count as compiled (the feature
1057/// enables them), and the gate allows every webview.
1058pub fn build_gate_with(
1059    policy: DevToolsPolicy,
1060    main_label: &str,
1061    registrations: &[ContentWebview],
1062    devtools_compiled: bool,
1063    dev_inspect: bool,
1064) -> Result<DevToolsGate, DevToolsError> {
1065    let devtools_compiled = devtools_compiled || dev_inspect;
1066    if registrations.is_empty() {
1067        return Ok(DevToolsGate {
1068            policy,
1069            main_label: main_label.to_owned(),
1070            content: BTreeSet::new(),
1071            dev_inspect,
1072        });
1073    }
1074    if policy != DevToolsPolicy::ContentOnly {
1075        return Err(DevToolsError::NotContentOnly);
1076    }
1077    if !devtools_compiled {
1078        return Err(DevToolsError::ContentDevtoolsNotCompiled);
1079    }
1080    let mut content = BTreeSet::new();
1081    for reg in registrations {
1082        if !valid_content_label(&reg.label) || !valid_label(&reg.window_label) {
1083            return Err(DevToolsError::InvalidLabel {
1084                label: reg.label.clone(),
1085            });
1086        }
1087        if reg.label == main_label || glob_match(&reg.label, main_label) {
1088            return Err(DevToolsError::MainWebview {
1089                label: reg.label.clone(),
1090            });
1091        }
1092        if !reg.app_acl_manifest {
1093            return Err(DevToolsError::AppAclManifestMissing {
1094                label: reg.label.clone(),
1095            });
1096        }
1097        for doc in &reg.capabilities {
1098            if let Some(capability) = capability_granting_ipc(doc, &reg.label, &reg.window_label)? {
1099                return Err(DevToolsError::ContentHasIpc {
1100                    label: reg.label.clone(),
1101                    capability,
1102                });
1103            }
1104        }
1105        content.insert(reg.label.clone());
1106    }
1107    Ok(DevToolsGate {
1108        policy,
1109        main_label: main_label.to_owned(),
1110        content,
1111        dev_inspect,
1112    })
1113}
1114
1115/// Open the inspector of a registered content webview. The only path from this crate to an open
1116/// inspector; every other webview is refused.
1117pub fn open_content_devtools<R: Runtime>(
1118    app: &AppHandle<R>,
1119    label: &str,
1120) -> Result<(), DevToolsError> {
1121    let gate = app
1122        .try_state::<DevToolsGate>()
1123        .ok_or(DevToolsError::NotInstalled)?;
1124    if gate.decide(label) == WebviewDecision::Deny {
1125        return Err(DevToolsError::NotAContentWebview {
1126            label: label.to_owned(),
1127        });
1128    }
1129    #[cfg(any(feature = "content-devtools", feature = "dev-inspect"))]
1130    {
1131        let webview = app
1132            .get_webview(label)
1133            .ok_or_else(|| DevToolsError::WebviewMissing {
1134                label: label.to_owned(),
1135            })?;
1136        webview.open_devtools();
1137        Ok(())
1138    }
1139    #[cfg(not(any(feature = "content-devtools", feature = "dev-inspect")))]
1140    Err(DevToolsError::ContentDevtoolsNotCompiled)
1141}
1142
1143// ---------------------------------------------------------------------------------------------
1144// Native, per-webview settings
1145// ---------------------------------------------------------------------------------------------
1146
1147/// The native DevTools setting of one webview, as [`enforce_switch`] needs it.
1148pub trait DevToolsSwitch {
1149    fn set(&mut self, enabled: bool) -> Result<(), String>;
1150    fn get(&self) -> Result<bool, String>;
1151}
1152
1153/// Set the native DevTools switch for `decision`. A denied webview is accepted only when the
1154/// switch was set to off and reads back off; a missing settings object, a failed call or a value
1155/// that is still on is [`DevToolsError::NativePolicyNotApplied`]. A webview that may be inspected
1156/// is never refused here: failing to turn inspection on is not a security failure.
1157pub fn enforce_switch(
1158    label: &str,
1159    decision: WebviewDecision,
1160    switch: Option<&mut dyn DevToolsSwitch>,
1161) -> Result<(), DevToolsError> {
1162    let deny = decision == WebviewDecision::Deny;
1163    let refuse = |detail: String| DevToolsError::NativePolicyNotApplied {
1164        label: label.to_owned(),
1165        detail,
1166    };
1167    let Some(switch) = switch else {
1168        return if deny {
1169            Err(refuse("the webview has no settings object".to_owned()))
1170        } else {
1171            Ok(())
1172        };
1173    };
1174    let set = switch.set(!deny);
1175    if !deny {
1176        return Ok(());
1177    }
1178    set.map_err(|e| refuse(format!("the setting was rejected: {e}")))?;
1179    match switch.get() {
1180        Ok(false) => Ok(()),
1181        Ok(true) => Err(refuse(
1182            "the setting is still on after it was turned off".to_owned(),
1183        )),
1184        Err(e) => Err(refuse(format!("the setting could not be read back: {e}"))),
1185    }
1186}
1187
1188/// Which groups of browser keys one webview does not act on. Each group is switched by one rule:
1189/// `devtools` by the DevTools decision (a denied webview), the others by the hardening options of
1190/// the same name. A key in no active group is left to the browser.
1191#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1192pub struct KeyRules {
1193    /// F12, Ctrl+Shift+I / J / C (inspector) and Ctrl+U (view source).
1194    pub devtools: bool,
1195    /// F5, Ctrl+R and Ctrl+Shift+R, and the keyboard's refresh key.
1196    pub reload: bool,
1197    /// Alt+Left, Alt+Right and the keyboard's back and forward keys.
1198    pub history: bool,
1199    /// Ctrl with plus, minus or zero, on the main block or the number pad.
1200    pub zoom: bool,
1201}
1202
1203/// The modifier keys held while a key went down.
1204#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1205pub struct KeyModifiers {
1206    pub ctrl: bool,
1207    pub shift: bool,
1208    pub alt: bool,
1209}
1210
1211/// Whether a webview with `rules` must not act on this key (a Windows virtual-key code). Find,
1212/// print and every key outside the active groups always pass.
1213pub fn is_blocked_accelerator(virtual_key: u32, held: KeyModifiers, rules: KeyRules) -> bool {
1214    let KeyModifiers { ctrl, shift, alt } = held;
1215    match virtual_key {
1216        // F12; I, J, C; U.
1217        0x7B => rules.devtools,
1218        0x49 | 0x4A | 0x43 => rules.devtools && ctrl && shift,
1219        0x55 => rules.devtools && ctrl,
1220        // F5, browser refresh; R.
1221        0x74 | 0xA8 => rules.reload,
1222        0x52 => rules.reload && ctrl,
1223        // Left, Right; browser back, forward.
1224        0x25 | 0x27 => rules.history && alt,
1225        0xA6 | 0xA7 => rules.history,
1226        // Plus, minus, zero; number pad add, subtract, zero.
1227        0xBB | 0xBD | 0x30 | 0x6B | 0x6D | 0x60 => rules.zoom && ctrl,
1228        _ => false,
1229    }
1230}
1231
1232/// Apply the DevTools decision to one live webview. Runs for every webview, whatever other
1233/// hardening modules the app opted out of.
1234///
1235/// `Err` (Windows only) means DevTools could not be disabled in a denied webview; the webview has
1236/// been closed and the caller must not go on setting it up. See `windows_impl::apply` for when
1237/// the refusal happens after this function has returned.
1238pub fn apply_native<R: Runtime>(
1239    webview: &Webview<R>,
1240    decision: WebviewDecision,
1241) -> Result<(), DevToolsError> {
1242    // Close an inspector that already exists, where the build has one. Tauri itself compiles the
1243    // inspector API into dev-profile builds (debug_assertions), so `debug_assertions` stays in this
1244    // list on purpose: it only ever CLOSES an inspector on a denied webview (hardening, not dev
1245    // control), and dropping it would leave a dev-profile inspector open.
1246    #[cfg(any(
1247        debug_assertions,
1248        feature = "content-devtools",
1249        feature = "dev-inspect"
1250    ))]
1251    if decision == WebviewDecision::Deny {
1252        webview.close_devtools();
1253    }
1254    #[cfg(target_os = "macos")]
1255    macos::apply(webview, decision);
1256    #[cfg(windows)]
1257    windows_impl::apply(webview, decision)?;
1258    #[cfg(not(any(target_os = "macos", windows)))]
1259    let _ = (webview, decision);
1260    Ok(())
1261}
1262
1263#[cfg(target_os = "macos")]
1264mod macos {
1265    use super::*;
1266    use objc2::msg_send;
1267    use objc2::runtime::AnyObject;
1268    use objc2_foundation::{ns_string, NSNumber};
1269
1270    pub fn apply<R: Runtime>(webview: &Webview<R>, decision: WebviewDecision) {
1271        let allow = decision != WebviewDecision::Deny;
1272        let _ = webview.with_webview(move |platform| unsafe {
1273            // SAFETY: Tauri hands over the live WKWebView; used only inside this main-thread closure.
1274            let wk = &*(platform.inner() as *mut AnyObject);
1275            // `isInspectable` (macOS 13.3+) is what Safari's Develop menu and `_inspector` honour.
1276            let responds: bool = msg_send![wk, respondsToSelector: objc2::sel!(setInspectable:)];
1277            if responds {
1278                let (): () = msg_send![wk, setInspectable: allow];
1279            }
1280            // The private `developerExtrasEnabled` preference adds "Inspect Element" to the context
1281            // menu and WebKit's own Cmd+Opt+I handler. Set explicitly in both directions so the
1282            // value never depends on a default (wry sets it true when its devtools are on).
1283            let config: objc2::rc::Retained<AnyObject> = msg_send![wk, configuration];
1284            let prefs: objc2::rc::Retained<AnyObject> = msg_send![&config, preferences];
1285            let flag = NSNumber::numberWithBool(allow);
1286            let (): () = msg_send![&prefs, setValue: Some(&*flag), forKey: ns_string!("developerExtrasEnabled")];
1287        });
1288    }
1289}
1290
1291#[cfg(windows)]
1292pub(crate) use windows_impl::set_hardening_keys;
1293
1294#[cfg(windows)]
1295mod windows_impl {
1296    use super::*;
1297    use crate::winapi::webview2_com::Microsoft::Web::WebView2::Win32::{
1298        ICoreWebView2ContextMenuItem, ICoreWebView2ContextMenuItemCollection,
1299        ICoreWebView2Settings, ICoreWebView2_11, COREWEBVIEW2_KEY_EVENT_KIND_KEY_DOWN,
1300        COREWEBVIEW2_KEY_EVENT_KIND_SYSTEM_KEY_DOWN,
1301    };
1302    use crate::winapi::webview2_com::{
1303        AcceleratorKeyPressedEventHandler, ContextMenuRequestedEventHandler,
1304    };
1305    use crate::winapi::windows::core::{Interface, PWSTR};
1306    use std::collections::HashMap;
1307    use std::sync::Mutex;
1308    use tauri::webview::PlatformWebview;
1309
1310    #[link(name = "ole32")]
1311    unsafe extern "system" {
1312        fn CoTaskMemFree(block: *const core::ffi::c_void);
1313    }
1314    #[link(name = "user32")]
1315    unsafe extern "system" {
1316        fn GetKeyState(virtual_key: i32) -> i16;
1317    }
1318    const VK_SHIFT: i32 = 0x10;
1319    const VK_CONTROL: i32 = 0x11;
1320    const VK_MENU: i32 = 0x12;
1321
1322    /// The key rules of each webview, by label. The one key handler of a webview reads them when
1323    /// a key goes down, so a rule set after the handler was added still applies.
1324    static KEY_RULES: Mutex<Option<HashMap<String, KeyRules>>> = Mutex::new(None);
1325
1326    fn update_key_rules(label: &str, change: impl FnOnce(&mut KeyRules)) {
1327        let mut rules = KEY_RULES
1328            .lock()
1329            .unwrap_or_else(|poison| poison.into_inner());
1330        change(
1331            rules
1332                .get_or_insert_with(HashMap::new)
1333                .entry(label.to_owned())
1334                .or_default(),
1335        );
1336    }
1337
1338    fn key_rules(label: &str) -> KeyRules {
1339        let rules = KEY_RULES
1340            .lock()
1341            .unwrap_or_else(|poison| poison.into_inner());
1342        rules
1343            .as_ref()
1344            .and_then(|map| map.get(label).copied())
1345            .unwrap_or_default()
1346    }
1347
1348    /// The hardening layer's key groups for one webview. It adds no key handler of its own and
1349    /// never turns all browser keys off.
1350    pub(crate) fn set_hardening_keys(label: &str, reload: bool, history: bool, zoom: bool) {
1351        update_key_rules(label, |rules| {
1352            rules.reload = reload;
1353            rules.history = history;
1354            rules.zoom = zoom;
1355        });
1356    }
1357
1358    fn key_is_down(virtual_key: i32) -> bool {
1359        // SAFETY: GetKeyState takes a virtual-key code and has no other precondition.
1360        unsafe { GetKeyState(virtual_key) < 0 }
1361    }
1362
1363    /// Event registrations this module made, by webview label: (context menu, accelerator keys).
1364    /// A webview that becomes ready again gets its old handlers removed before the new ones are
1365    /// added, so each webview has one of each.
1366    static HANDLERS: Mutex<Option<HashMap<String, Registered>>> = Mutex::new(None);
1367
1368    /// The event tokens of one webview: (context menu, accelerator keys).
1369    type Registered = (Option<i64>, Option<i64>);
1370
1371    fn is_inspect_item(item: &ICoreWebView2ContextMenuItem) -> bool {
1372        let mut name = PWSTR::null();
1373        // SAFETY: Name writes a CoTaskMem string, which is copied and then freed here.
1374        unsafe {
1375            if item.Name(&mut name).is_err() || name.is_null() {
1376                return false;
1377            }
1378            let text = name.to_string().unwrap_or_default();
1379            CoTaskMemFree(name.0 as *const core::ffi::c_void);
1380            text.to_ascii_lowercase().contains("inspect")
1381        }
1382    }
1383
1384    /// Remove the items for which `remove` is true.
1385    unsafe fn retain_items(
1386        items: &ICoreWebView2ContextMenuItemCollection,
1387        remove: impl Fn(&ICoreWebView2ContextMenuItem) -> bool,
1388    ) -> usize {
1389        let mut count: u32 = 0;
1390        if items.Count(&mut count).is_err() {
1391            return 0;
1392        }
1393        let mut i = count;
1394        while i > 0 {
1395            i -= 1;
1396            if let Ok(item) = items.GetValueAtIndex(i) {
1397                if remove(&item) {
1398                    let _ = items.RemoveValueAtIndex(i);
1399                }
1400            }
1401        }
1402        let mut left: u32 = 0;
1403        let _ = items.Count(&mut left);
1404        left as usize
1405    }
1406
1407    struct LiveSwitch<'a>(&'a ICoreWebView2Settings);
1408
1409    impl DevToolsSwitch for LiveSwitch<'_> {
1410        fn set(&mut self, enabled: bool) -> Result<(), String> {
1411            // SAFETY: a live settings object, used on the thread WebView2 gave it to us on.
1412            unsafe { self.0.SetAreDevToolsEnabled(enabled) }.map_err(|e| e.to_string())
1413        }
1414        fn get(&self) -> Result<bool, String> {
1415            let mut enabled = Default::default();
1416            // SAFETY: as above; `enabled` is a valid out pointer.
1417            unsafe { self.0.AreDevToolsEnabled(&mut enabled) }.map_err(|e| e.to_string())?;
1418            Ok(enabled.as_bool())
1419        }
1420    }
1421
1422    /// WebView2 side of the policy. Compiled by the windows-2025 CI job; nobody has run it against
1423    /// a live WebView2 on a Windows machine.
1424    ///
1425    /// `Err` means DevTools could not be disabled in a denied webview. The webview is then closed
1426    /// by this function. When Tauri runs the native closure later rather than at once, the result
1427    /// here is `Ok` and the closure closes the webview itself on failure.
1428    pub fn apply<R: Runtime>(
1429        webview: &Webview<R>,
1430        decision: WebviewDecision,
1431    ) -> Result<(), DevToolsError> {
1432        let label = webview.label().to_owned();
1433        let refused = webview.clone();
1434        let (report, reported) = std::sync::mpsc::channel();
1435        let on_main = label.clone();
1436        let dispatched = webview.with_webview(move |platform| {
1437            let outcome = apply_on_main(&platform, &on_main, decision);
1438            if let Err(error) = &outcome {
1439                eprintln!("{error}");
1440                // Closed from another thread: this closure still holds the platform webview.
1441                std::thread::spawn(move || {
1442                    let _ = refused.close();
1443                });
1444            }
1445            let _ = report.send(outcome);
1446        });
1447        if let Err(error) = dispatched {
1448            if decision != WebviewDecision::Deny {
1449                return Ok(());
1450            }
1451            let error = DevToolsError::NativePolicyNotApplied {
1452                label,
1453                detail: format!("the webview could not be reached: {error}"),
1454            };
1455            eprintln!("{error}");
1456            let _ = webview.close();
1457            return Err(error);
1458        }
1459        reported.try_recv().unwrap_or(Ok(()))
1460    }
1461
1462    fn apply_on_main(
1463        platform: &PlatformWebview,
1464        label: &str,
1465        decision: WebviewDecision,
1466    ) -> Result<(), DevToolsError> {
1467        let allow = decision != WebviewDecision::Deny;
1468        // Developer inspection leaves the context menu exactly as WebView2 has it.
1469        let dev = decision == WebviewDecision::AllowDev;
1470        // SAFETY: Tauri hands over the live controller; used only inside this main-thread call.
1471        unsafe {
1472            let controller = platform.controller();
1473            let core = controller.CoreWebView2().ok();
1474            let settings = core.as_ref().and_then(|core| core.Settings().ok());
1475            let mut live = settings.as_ref().map(LiveSwitch);
1476            enforce_switch(
1477                label,
1478                decision,
1479                live.as_mut().map(|live| live as &mut dyn DevToolsSwitch),
1480            )?;
1481            let Some(core) = core else {
1482                return Ok(());
1483            };
1484            let wv11 = core.cast::<ICoreWebView2_11>().ok();
1485
1486            let mut handlers = HANDLERS.lock().unwrap_or_else(|poison| poison.into_inner());
1487            let (old_menu, old_keys) = handlers
1488                .get_or_insert_with(HashMap::new)
1489                .remove(label)
1490                .unwrap_or((None, None));
1491            if let (Some(wv11), Some(token)) = (&wv11, old_menu) {
1492                let _ = wv11.remove_ContextMenuRequested(token);
1493            }
1494            if let Some(token) = old_keys {
1495                let _ = controller.remove_AcceleratorKeyPressed(token);
1496            }
1497
1498            // One key handler per webview, for the whole shell. It stops exactly the keys this
1499            // webview's rules name (inspector and view-source keys when denied; reload, history
1500            // and zoom keys when the hardening asks) and leaves every other key to WebView2.
1501            update_key_rules(label, |rules| rules.devtools = !allow);
1502            let mut keys = None;
1503            {
1504                let label = label.to_owned();
1505                let handler =
1506                    AcceleratorKeyPressedEventHandler::create(Box::new(move |_sender, args| {
1507                        let Some(args) = args else { return Ok(()) };
1508                        let mut kind = Default::default();
1509                        args.KeyEventKind(&mut kind)?;
1510                        if kind != COREWEBVIEW2_KEY_EVENT_KIND_KEY_DOWN
1511                            && kind != COREWEBVIEW2_KEY_EVENT_KIND_SYSTEM_KEY_DOWN
1512                        {
1513                            return Ok(());
1514                        }
1515                        let mut key: u32 = 0;
1516                        args.VirtualKey(&mut key)?;
1517                        let held = KeyModifiers {
1518                            ctrl: key_is_down(VK_CONTROL),
1519                            shift: key_is_down(VK_SHIFT),
1520                            alt: key_is_down(VK_MENU),
1521                        };
1522                        if is_blocked_accelerator(key, held, key_rules(&label)) {
1523                            args.SetHandled(true)?;
1524                        }
1525                        Ok(())
1526                    }));
1527                let mut token: i64 = 0;
1528                if controller
1529                    .add_AcceleratorKeyPressed(&handler, &mut token)
1530                    .is_ok()
1531                {
1532                    keys = Some(token);
1533                }
1534            }
1535
1536            // The default context menu carries "Inspect". Denied: remove it from every menu.
1537            // Content: keep only it, and show no menu when it is absent.
1538            let mut menu = None;
1539            if let (false, Some(wv11)) = (dev, &wv11) {
1540                let handler =
1541                    ContextMenuRequestedEventHandler::create(Box::new(move |_sender, args| {
1542                        let Some(args) = args else { return Ok(()) };
1543                        let items = args.MenuItems()?;
1544                        if allow {
1545                            let left = retain_items(&items, |it| !is_inspect_item(it));
1546                            if left == 0 {
1547                                args.SetHandled(true)?;
1548                            }
1549                        } else {
1550                            retain_items(&items, is_inspect_item);
1551                        }
1552                        Ok(())
1553                    }));
1554                let mut token: i64 = 0;
1555                if wv11.add_ContextMenuRequested(&handler, &mut token).is_ok() {
1556                    menu = Some(token);
1557                }
1558            }
1559            if let Some(map) = handlers.as_mut() {
1560                map.insert(label.to_owned(), (menu, keys));
1561            }
1562            Ok(())
1563        }
1564    }
1565}