1use 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
39pub 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
42pub 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
52pub const DEV_INSPECT_MARKER: &str = "RIGHTKIT-SHELL-DEV-INSPECT-COMPILED";
58
59pub const DEV_INSPECT_COMPILED: bool = cfg!(feature = "dev-inspect");
61
62#[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#[cfg(feature = "dev-inspect")]
80#[inline(never)]
81fn keep_dev_inspect_marker() -> u8 {
82 unsafe { std::ptr::read_volatile(DEV_INSPECT_MARKER_BYTES.as_ptr()) }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88pub enum DevToolsPolicy {
89 #[default]
91 Denied,
92 ContentOnly,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum WebviewDecision {
100 Deny,
102 AllowContent,
104 AllowDev,
108}
109
110#[derive(Debug, Clone)]
118pub struct ContentWebview {
119 pub label: String,
122 pub window_label: String,
124 pub capabilities: Vec<String>,
127 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 pub fn capability(mut self, json: &str) -> Self {
144 self.capabilities.push(json.to_owned());
145 self
146 }
147 pub fn app_acl_manifest(mut self, declared: bool) -> Self {
149 self.app_acl_manifest = declared;
150 self
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum DevToolsError {
157 RemoteDebuggingArgument { arg: String },
159 BrowserPolicyInjects { source: String, switch: String },
161 BrowserRuntimeRedirected { source: String, folder: String },
164 RegistryUnreadable { detail: String },
166 RemoteDebugPortLive { path: PathBuf },
168 NotContentOnly,
170 ContentDevtoolsNotCompiled,
172 InvalidLabel { label: String },
174 MainWebview { label: String },
176 ContentHasIpc { label: String, capability: String },
178 CapabilitiesUnreadable { detail: String },
180 AppAclManifestMissing { label: String },
182 NotAContentWebview { label: String },
184 NotInstalled,
186 WebviewMissing { label: String },
188 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
247fn unrev(s: &str) -> String {
254 s.chars().rev().collect()
255}
256
257fn 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
272fn 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
283pub 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
306pub fn argv_violations(args: &[String]) -> Vec<String> {
308 args.iter().flat_map(|a| forbidden_switches_in(a)).collect()
309}
310
311pub 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#[derive(Debug, Clone, PartialEq, Eq)]
328pub struct RegValue {
329 pub name: String,
330 pub value: String,
331}
332
333const REG_TEXT_TYPES: [u32; 3] = [1, 2, 7];
335
336pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub enum PolicyKeyOpen {
361 Opened,
363 Absent,
366 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
381pub 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
397pub 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
406pub fn runtime_folder_env_name() -> String {
408 unrev("REDLOF_ELBATUCEXE_RESWORB_2WEIVBEW")
409}
410
411pub 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
425pub 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
443pub 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
472pub 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
487static EXTRA_USER_DATA_DIRS: std::sync::Mutex<Vec<PathBuf>> = std::sync::Mutex::new(Vec::new());
489
490pub 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
505pub struct GuardReport {
506 pub scrubbed_env: Vec<String>,
508}
509
510static GUARD: OnceLock<Result<GuardReport, DevToolsError>> = OnceLock::new();
511static PROCESS_START: OnceLock<SystemTime> = OnceLock::new();
512
513pub 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 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#[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#[cfg(not(feature = "dev-inspect"))]
606fn announce_dev_inspect() -> GuardReport {
607 GuardReport::default()
608}
609
610#[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 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 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 unsafe { RegCloseKey(self.0) };
657 }
658 }
659
660 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 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 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 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#[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#[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#[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#[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
800pub const DEBUG_PORT_FILE: &str = "DevToolsActivePort";
802
803pub fn is_live_debug_port_file(modified: SystemTime, process_start: SystemTime) -> bool {
805 modified + Duration::from_secs(2) >= process_start
806}
807
808pub 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 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
827pub fn verify_not_remote_debuggable<R: Runtime>(app: &AppHandle<R>) -> Result<(), DevToolsError> {
830 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
860pub 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#[derive(Debug, Clone, PartialEq, Eq)]
877pub struct DevToolsGate {
878 policy: DevToolsPolicy,
879 main_label: String,
880 content: BTreeSet<String>,
881 dev_inspect: bool,
883}
884
885impl DevToolsGate {
886 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 pub fn is_dev_inspect(&self) -> bool {
898 self.dev_inspect
899 }
900 pub fn policy(&self) -> DevToolsPolicy {
901 self.policy
902 }
903 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 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
938fn 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
947pub 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
964pub 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
1004pub 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
1043pub 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
1054pub 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(®.label) || !valid_label(®.window_label) {
1083 return Err(DevToolsError::InvalidLabel {
1084 label: reg.label.clone(),
1085 });
1086 }
1087 if reg.label == main_label || glob_match(®.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 ®.capabilities {
1098 if let Some(capability) = capability_granting_ipc(doc, ®.label, ®.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
1115pub 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
1143pub trait DevToolsSwitch {
1149 fn set(&mut self, enabled: bool) -> Result<(), String>;
1150 fn get(&self) -> Result<bool, String>;
1151}
1152
1153pub 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1192pub struct KeyRules {
1193 pub devtools: bool,
1195 pub reload: bool,
1197 pub history: bool,
1199 pub zoom: bool,
1201}
1202
1203#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1205pub struct KeyModifiers {
1206 pub ctrl: bool,
1207 pub shift: bool,
1208 pub alt: bool,
1209}
1210
1211pub fn is_blocked_accelerator(virtual_key: u32, held: KeyModifiers, rules: KeyRules) -> bool {
1214 let KeyModifiers { ctrl, shift, alt } = held;
1215 match virtual_key {
1216 0x7B => rules.devtools,
1218 0x49 | 0x4A | 0x43 => rules.devtools && ctrl && shift,
1219 0x55 => rules.devtools && ctrl,
1220 0x74 | 0xA8 => rules.reload,
1222 0x52 => rules.reload && ctrl,
1223 0x25 | 0x27 => rules.history && alt,
1225 0xA6 | 0xA7 => rules.history,
1226 0xBB | 0xBD | 0x30 | 0x6B | 0x6D | 0x60 => rules.zoom && ctrl,
1228 _ => false,
1229 }
1230}
1231
1232pub fn apply_native<R: Runtime>(
1239 webview: &Webview<R>,
1240 decision: WebviewDecision,
1241) -> Result<(), DevToolsError> {
1242 #[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 let wk = &*(platform.inner() as *mut AnyObject);
1275 let responds: bool = msg_send![wk, respondsToSelector: objc2::sel!(setInspectable:)];
1277 if responds {
1278 let (): () = msg_send![wk, setInspectable: allow];
1279 }
1280 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 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 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 unsafe { GetKeyState(virtual_key) < 0 }
1361 }
1362
1363 static HANDLERS: Mutex<Option<HashMap<String, Registered>>> = Mutex::new(None);
1367
1368 type Registered = (Option<i64>, Option<i64>);
1370
1371 fn is_inspect_item(item: &ICoreWebView2ContextMenuItem) -> bool {
1372 let mut name = PWSTR::null();
1373 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 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 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 unsafe { self.0.AreDevToolsEnabled(&mut enabled) }.map_err(|e| e.to_string())?;
1418 Ok(enabled.as_bool())
1419 }
1420 }
1421
1422 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 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 let dev = decision == WebviewDecision::AllowDev;
1470 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 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 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}