1use 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
40pub const OWNER_DECISION: &str = "Every Right app has absolutely NO access to DevTools: blocked at each and every level that is possible, for the app, its shell and its own UI. The single exception is ViewRight: it opens HTML documents, and DevTools must be available INSIDE that opened HTML content only (the content frame), never for ViewRight's own UI or shell.";
42
43pub 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
53pub const DEV_INSPECT_MARKER: &str = "RIGHTKIT-SHELL-DEV-INSPECT-COMPILED";
59
60pub const DEV_INSPECT_COMPILED: bool = false;
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum DevToolsPolicy {
67 #[default]
69 Denied,
70 ContentOnly,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum WebviewDecision {
78 Deny,
80 AllowContent,
82 AllowDev,
85}
86
87#[derive(Debug, Clone)]
95pub struct ContentWebview {
96 pub label: String,
99 pub window_label: String,
101 pub capabilities: Vec<String>,
104 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 pub fn capability(mut self, json: &str) -> Self {
121 self.capabilities.push(json.to_owned());
122 self
123 }
124 pub fn app_acl_manifest(mut self, declared: bool) -> Self {
126 self.app_acl_manifest = declared;
127 self
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum DevToolsError {
134 RemoteDebuggingArgument { arg: String },
136 BrowserPolicyInjects { source: String, switch: String },
138 BrowserRuntimeRedirected { source: String, folder: String },
141 RegistryUnreadable { detail: String },
143 RemoteDebugPortLive { path: PathBuf },
145 NotContentOnly,
147 ContentDevtoolsNotCompiled,
149 InvalidLabel { label: String },
151 MainWebview { label: String },
153 ContentHasIpc { label: String, capability: String },
155 CapabilitiesUnreadable { detail: String },
157 AppAclManifestMissing { label: String },
159 NotAContentWebview { label: String },
161 NotInstalled,
163 WebviewMissing { label: String },
165 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
224fn unrev(s: &str) -> String {
231 s.chars().rev().collect()
232}
233
234fn 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
249fn 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
260pub 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
283pub fn argv_violations(args: &[String]) -> Vec<String> {
285 args.iter().flat_map(|a| forbidden_switches_in(a)).collect()
286}
287
288pub 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#[derive(Debug, Clone, PartialEq, Eq)]
305pub struct RegValue {
306 pub name: String,
307 pub value: String,
308}
309
310const REG_TEXT_TYPES: [u32; 3] = [1, 2, 7];
312
313pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
337pub enum PolicyKeyOpen {
338 Opened,
340 Absent,
343 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
358pub 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
374pub 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
383pub fn runtime_folder_env_name() -> String {
385 unrev("REDLOF_ELBATUCEXE_RESWORB_2WEIVBEW")
386}
387
388pub 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
402pub 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
420pub 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
449pub 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
464static EXTRA_USER_DATA_DIRS: std::sync::Mutex<Vec<PathBuf>> = std::sync::Mutex::new(Vec::new());
466
467pub 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
482pub struct GuardReport {
483 pub scrubbed_env: Vec<String>,
485}
486
487static GUARD: OnceLock<Result<GuardReport, DevToolsError>> = OnceLock::new();
488static PROCESS_START: OnceLock<SystemTime> = OnceLock::new();
489
490pub 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 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#[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 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 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 unsafe { RegCloseKey(self.0) };
610 }
611 }
612
613 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 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 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 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#[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#[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#[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#[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
753pub const DEBUG_PORT_FILE: &str = "DevToolsActivePort";
755
756pub fn is_live_debug_port_file(modified: SystemTime, process_start: SystemTime) -> bool {
758 modified + Duration::from_secs(2) >= process_start
759}
760
761pub 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 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
780pub 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
809pub 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#[derive(Debug, Clone, PartialEq, Eq)]
826pub struct DevToolsGate {
827 policy: DevToolsPolicy,
828 main_label: String,
829 content: BTreeSet<String>,
830 dev_inspect: bool,
832}
833
834impl DevToolsGate {
835 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 pub fn is_dev_inspect(&self) -> bool {
847 self.dev_inspect
848 }
849 pub fn policy(&self) -> DevToolsPolicy {
850 self.policy
851 }
852 pub fn content_labels(&self) -> Vec<String> {
854 self.content.iter().cloned().collect()
855 }
856 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
880fn 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
889pub 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
906pub 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
946pub 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
985pub 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
996pub 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(®.label) || !valid_label(®.window_label) {
1024 return Err(DevToolsError::InvalidLabel {
1025 label: reg.label.clone(),
1026 });
1027 }
1028 if reg.label == main_label || glob_match(®.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 ®.capabilities {
1039 if let Some(capability) = capability_granting_ipc(doc, ®.label, ®.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
1056pub 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
1084pub trait DevToolsSwitch {
1090 fn set(&mut self, enabled: bool) -> Result<(), String>;
1091 fn get(&self) -> Result<bool, String>;
1092}
1093
1094pub 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1133pub struct KeyRules {
1134 pub devtools: bool,
1136 pub reload: bool,
1138 pub history: bool,
1140 pub zoom: bool,
1142}
1143
1144#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1146pub struct KeyModifiers {
1147 pub ctrl: bool,
1148 pub shift: bool,
1149 pub alt: bool,
1150}
1151
1152pub fn is_blocked_accelerator(virtual_key: u32, held: KeyModifiers, rules: KeyRules) -> bool {
1155 let KeyModifiers { ctrl, shift, alt } = held;
1156 match virtual_key {
1157 0x7B => rules.devtools,
1159 0x49 | 0x4A | 0x43 => rules.devtools && ctrl && shift,
1160 0x55 => rules.devtools && ctrl,
1161 0x74 | 0xA8 => rules.reload,
1163 0x52 => rules.reload && ctrl,
1164 0x25 | 0x27 => rules.history && alt,
1166 0xA6 | 0xA7 => rules.history,
1167 0xBB | 0xBD | 0x30 | 0x6B | 0x6D | 0x60 => rules.zoom && ctrl,
1169 _ => false,
1170 }
1171}
1172
1173pub fn apply_native<R: Runtime>(
1180 webview: &Webview<R>,
1181 decision: WebviewDecision,
1182) -> Result<(), DevToolsError> {
1183 #[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 let wk = &*(platform.inner() as *mut AnyObject);
1212 let responds: bool = msg_send![wk, respondsToSelector: objc2::sel!(setInspectable:)];
1214 if responds {
1215 let (): () = msg_send![wk, setInspectable: allow];
1216 }
1217 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 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 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 unsafe { GetKeyState(virtual_key) < 0 }
1298 }
1299
1300 static HANDLERS: Mutex<Option<HashMap<String, Registered>>> = Mutex::new(None);
1304
1305 type Registered = (Option<i64>, Option<i64>);
1307
1308 fn is_inspect_item(item: &ICoreWebView2ContextMenuItem) -> bool {
1309 let mut name = PWSTR::null();
1310 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 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 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 unsafe { self.0.AreDevToolsEnabled(&mut enabled) }.map_err(|e| e.to_string())?;
1355 Ok(enabled.as_bool())
1356 }
1357 }
1358
1359 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 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 let dev = decision == WebviewDecision::AllowDev;
1407 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 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 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}