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