From bc0734ab25c22a01532cff47c56a0e6c79637471 Mon Sep 17 00:00:00 2001 From: zipg Date: Fri, 23 Oct 2026 01:10:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(windows):=20=E5=8C=BA=E5=88=86=20WebView2?= =?UTF-8?q?=20=E8=BF=90=E8=A1=8C=E6=97=B6=E9=97=AE=E9=A2=98=E4=B8=8E?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E8=BD=AF=E4=BB=B6=E6=8B=A6=E6=88=AA=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=9C=89=E7=95=8C=E5=85=BC=E5=AE=B9=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E9=98=B6=E6=A2=AF=20(#13036)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(windows): diagnose WebView2 runtime failures and recover startup On Windows the "main window never appears" failure has three very different causes that used to share one misleading "enterprise compatibility mode" retry: WebView2 runtime missing, WebView2 runtime registered but damaged, and a security product blocking WebView2. This change separates them before Tauri creates a window and turns the single retry into a bounded recovery ladder. - webview2_preflight: three-state runtime health (available / missing / registered_but_damaged), runtime version and install kind, installed security products, msedgewebview2 process count, Zone.Identifier and integrity level, plus an STA environment-creation probe with a hard timeout and HRESULT capture - startup_recovery: 11-profile bounded compatibility ladder (isolated user data, sandbox/GPU off, RendererCodeIntegrity off for old runtimes, in-process GPU, temp data, scan delay, warm-up child, AppCompat Windows 8 layer, elevated relaunch, relocated copy, single-process experiment) with per-profile user data folders, fast-fail window probing, chain handoff through environment variables, bounded watchdog and silent auto-remembering of the winning profile - startup_messages: native-dialog i18n catalog (zh-CN / zh-TW / en, English fallback); runtime problems point at the Windows 10 offline package that bundles WebView2, support codes stay in startup.log, success stays silent - windows_appcompat: reversible per-user AppCompat Windows 8 layer used by a late profile - wry: make WEBVIEW2_USER_DATA_FOLDER / WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS effective at runtime (union --disable-features lists) and honor WEBVIEW2_BROWSER_EXECUTABLE_FOLDER on any Windows version - bounded diagnostic logging (runtime health, attempts, stages, HRESULTs) and DBX_DISABLE_* / DBX_TRY_COMPAT_ON_BROKEN_WEBVIEW2 escape hatches The standard startup path is unchanged on machines with a healthy runtime. * test(transfer): initialize target_column_name_case in live Oracle transfer test The column-name-case feature added a required field to TransferRequest but this live Oracle test initializer was not updated, so rust-test/rust-fmt-clippy fail on main as soon as the Rust jobs run (E0063). This is a pre-existing CI blocker for this PR; keep the fix as a separate commit so it can be split out if preferred. * test(windows): fix inverted marker assertions and make them version-agnostic The marker test asserted expected_marker_mismatch(...) == true for the matching version/profile pair (inverted), and hard-coded 0.6.49 while the marker is scoped to CARGO_PKG_VERSION (0.6.50 on main). Use the crate version and assert both directions; this is what failed rust-test on CI. * fix(windows): preserve existing AppCompat layers and track ownership * style(windows): keep platform module declarations grouped --------- Co-authored-by: zipg <4047349+zipg@users.noreply.github.com> --- Cargo.lock | 1 + src-tauri/Cargo.toml | 8 +- src-tauri/src/lib.rs | 95 ++ src-tauri/src/startup_messages.rs | 147 ++ src-tauri/src/startup_recovery.rs | 1552 +++++++++++++++++----- src-tauri/src/startup_recovery_noop.rs | 14 + src-tauri/src/webview2_preflight.rs | 1476 ++++++++++++++++++++ src-tauri/src/windows_appcompat.rs | 473 +++++++ src-tauri/src/windows_appcompat_noop.rs | 20 + src-tauri/src/windows_appcompat_state.rs | 158 +++ vendor/wry/DBX-PATCH.md | 13 + vendor/wry/src/webview2/mod.rs | 151 ++- 12 files changed, 3793 insertions(+), 315 deletions(-) create mode 100644 src-tauri/src/startup_messages.rs create mode 100644 src-tauri/src/webview2_preflight.rs create mode 100644 src-tauri/src/windows_appcompat.rs create mode 100644 src-tauri/src/windows_appcompat_noop.rs create mode 100644 src-tauri/src/windows_appcompat_state.rs diff --git a/Cargo.lock b/Cargo.lock index 7461ef83a4..4f61a5ea5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2203,6 +2203,7 @@ dependencies = [ "tokio-util", "uuid", "webview2-com", + "windows 0.61.3", "windows-core 0.61.2", "windows-sys 0.61.2", "wry", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b49d05bb0c..6df20722d1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -98,9 +98,13 @@ gtk = "0.18" [target.'cfg(target_os = "windows")'.dependencies] # 与 vendored wry / tauri-runtime-wry 同版本(Cargo.lock 只有一份 0.38.x),用于在 -# 快捷键录入态运行时切换 WebView2 的浏览器加速键开关(见 commands/shortcut_recording.rs)。 +# 快捷键录入态运行时切换 WebView2 的浏览器加速键开关(见 commands/shortcut_recording.rs), +# 以及启动兼容模式下直接探测 WebView2 环境能否创建(见 webview2_preflight.rs)。 webview2-com = "0.38" +# `windows` crate 与 wry/webview2-com 使用同一份 0.61,仅用其 core 类型 +# (HSTRING/PCWSTR/PWSTR/Interface),供 WebView2 预检调用 COM 接口时构造参数。 +windows = { version = "0.61", default-features = false } # `cast`(Settings → Settings3 的 QueryInterface)来自 windows-core 的 Interface trait, # 版本与 vendored wry / webview2-com-sys 一致。 windows-core = "0.61" -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 81a3f10884..1e303570e7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,15 +11,26 @@ mod migration_gate; mod models; mod plugin_ui_protocol; #[cfg(any(target_os = "windows", test))] +mod startup_messages; +#[cfg(any(target_os = "windows", test))] mod startup_recovery; #[cfg(all(not(target_os = "windows"), not(test)))] #[path = "startup_recovery_noop.rs"] mod startup_recovery; #[cfg(any(target_os = "windows", test))] mod webview2_legacy_css; +#[cfg(target_os = "windows")] +mod webview2_preflight; #[cfg(any(target_os = "windows", test))] mod webview2_recovery; mod window_state_guard; +#[cfg(target_os = "windows")] +mod windows_appcompat; +#[cfg(not(target_os = "windows"))] +#[path = "windows_appcompat_noop.rs"] +mod windows_appcompat; +#[cfg(any(target_os = "windows", test))] +mod windows_appcompat_state; use commands::connection::AppState; use dbx_core::persistence::secret_codec::SecretKeyPolicy; @@ -641,21 +652,26 @@ fn apply_linux_webkit_rendering_workarounds() { fn show_main_window(app: &tauri::AppHandle) -> bool { let Some(window) = app.get_webview_window("main") else { eprintln!("[WINDOW] show_main_window: no \"main\" webview window to reveal"); + startup_recovery::record("show_main_window: main webview window is missing"); return false; }; if let Err(err) = window.show() { eprintln!("[WINDOW] show_main_window: show() failed: {err}"); + startup_recovery::record(format!("show_main_window: show() failed: {err}")); } if let Err(err) = window.unminimize() { eprintln!("[WINDOW] show_main_window: unminimize() failed: {err}"); + startup_recovery::record(format!("show_main_window: unminimize() failed: {err}")); } if let Err(err) = window.set_focus() { eprintln!("[WINDOW] show_main_window: set_focus() failed: {err}"); + startup_recovery::record(format!("show_main_window: set_focus() failed: {err}")); } match window.is_visible() { Ok(visible) => visible, Err(err) => { eprintln!("[WINDOW] show_main_window: is_visible() failed: {err}"); + startup_recovery::record(format!("show_main_window: is_visible() failed: {err}")); false } } @@ -1729,6 +1745,17 @@ fn route_external_commands( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // WebView2 warm-up child mode: when a compatibility profile asks for a + // preflight, the parent re-launches this executable with + // DBX_WEBVIEW2_PREFLIGHT_CHILD=1. The child only creates and releases a + // WebView2 environment (forcing AV products to scan/trust msedgewebview2.exe) + // and exits before any Tauri/window setup. Doing this in a separate process + // isolates a hanging environment creation from the real startup attempt. + #[cfg(target_os = "windows")] + if webview2_preflight::run_child_if_requested() { + return; + } + // Metadata/completion command chains nest very large async futures and can // exhaust tokio's default 2 MiB worker stack, which aborts the process with // STATUS_STACK_OVERFLOW. Share the roomier stack the backup worker and Web @@ -1741,6 +1768,68 @@ pub fn run() { startup_recovery::initialize(); rustls::crypto::aws_lc_rs::default_provider().install_default().expect("Failed to install rustls crypto provider"); append_startup_probe("runtime prerequisites configured"); + // Record the WebView2 / security-software environment before any window is + // created. This is the first thing to look at when a startup recovery chain + // fails: it distinguishes a missing/hooked WebView2 runtime from an AV + // product that blocks the child browser process. + #[cfg(target_os = "windows")] + let webview2_runtime_health = webview2_preflight::runtime_health(); + #[cfg(target_os = "windows")] + append_startup_probe(format!("webview2 runtime_health={}", webview2_runtime_health.as_str())); + + #[cfg(target_os = "windows")] + { + // Collect WebView2/AV diagnostics on a worker thread with a hard deadline: + // on machines where a security product hooks the WebView2 loader (the very + // machines this diagnostics targets) the version query must never be able + // to delay the real startup path. A timed-out worker is abandoned and dies + // with the process. + let (diagnostics_tx, diagnostics_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let diagnostics = webview2_preflight::collect_diagnostics(); + let _ = diagnostics_tx.send(webview2_preflight::format_diagnostics(&diagnostics)); + }); + match diagnostics_rx.recv_timeout(std::time::Duration::from_secs(3)) { + Ok(lines) => { + for line in lines { + append_startup_probe(line); + } + } + Err(_) => append_startup_probe("webview2 diagnostics timed out after 3s; continuing startup"), + } + } + // A missing/damaged WebView2 runtime is the #1 cause of "the window never + // appears" on enterprise Windows machines, and it used to be misdiagnosed as a + // security product blocking WebView2 (the compatibility ladder then retried for + // minutes). Detect it before Tauri tries to create the window and show one + // clear, actionable message instead. + #[cfg(target_os = "windows")] + { + // Diagnostic escape hatch: a user who wants the AV compatibility ladder to + // run even though the WebView2 runtime is missing/damaged can force it. + // This makes no difference on a healthy runtime but lets support reproduce + // "try everything" behaviour on a broken machine. + let force_compat_on_broken_runtime = + std::env::var_os("DBX_TRY_COMPAT_ON_BROKEN_WEBVIEW2").is_some_and(|value| value == "1"); + match webview2_runtime_health { + webview2_preflight::WebView2RuntimeHealth::Missing if !force_compat_on_broken_runtime => { + startup_recovery::handle_missing_webview2_runtime(); + return; + } + webview2_preflight::WebView2RuntimeHealth::RegisteredButDamaged if !force_compat_on_broken_runtime => { + startup_recovery::handle_damaged_webview2_runtime( + webview2_preflight::collect_diagnostics().runtime_version.as_deref(), + ); + return; + } + _ => {} + } + } + + // Profile-specific pre-Tauri work (scan delay, WebView2 warm-up preflight). + // This must run before `Builder::default().build()` because wry reads the + // WebView2 environment variables during window creation. + startup_recovery::run_profile_pre_tauri_hooks(); #[cfg(target_os = "linux")] apply_linux_webkit_rendering_workarounds(); @@ -1849,6 +1938,9 @@ pub fn run() { "early main window show requested; {}", main_window_probe_state(app.handle()) )); + startup_recovery::report_window_visibility_probe(startup_recovery::probe_main_window_visible( + app.handle(), + )); } append_startup_probe("resolving app data dir"); @@ -2062,6 +2154,9 @@ pub fn run() { "final main window show requested; {}", main_window_probe_state(app.handle()) )); + startup_recovery::report_window_visibility_probe(startup_recovery::probe_main_window_visible( + app.handle(), + )); } #[cfg(any(windows, target_os = "linux"))] let _ = app.deep_link().register_all(); diff --git a/src-tauri/src/startup_messages.rs b/src-tauri/src/startup_messages.rs new file mode 100644 index 0000000000..caefc8ec5c --- /dev/null +++ b/src-tauri/src/startup_messages.rs @@ -0,0 +1,147 @@ +//! Native-dialog messages for Windows startup failures, centralized for i18n. +//! +//! Startup-failure dialogs are shown before the webview exists (a missing or +//! damaged WebView2 runtime, or a window that never appears), so they cannot use +//! the frontend i18n resources. The Rust side keeps a small catalog here instead +//! of hard-coding `if locale.starts_with("zh")` branches at every call site. +//! +//! Coverage follows the existing native-UI precedent (`tray_menu_labels_for_locale` +//! in `lib.rs`): Simplified Chinese, Traditional Chinese and English are +//! translated; every other locale falls back to English. The frontend locales +//! remain the authoritative translation source for in-app text. +//! +//! Placeholders in templates: +//! - `{{log}}` — full path of `startup.log` +//! - `{{url}}` — direct download URL of the Windows 10 offline package + +/// Messages for one language. Templates contain `{{log}}` / `{{url}}` placeholders. +pub(crate) struct StartupMessages { + pub title: &'static str, + pub missing_runtime: &'static str, + pub damaged_runtime: &'static str, + pub compatibility_failed: &'static str, + pub recovery_disabled: &'static str, +} + +impl StartupMessages { + /// Fills the template placeholders. Unknown placeholders are left untouched + /// so a translation mistake is visible instead of silently producing an empty + /// dialog. + pub(crate) fn render(&self, template: &'static str, log_path: &str, offline_url: &str) -> String { + template.replace("{{log}}", log_path).replace("{{url}}", offline_url) + } +} + +const EN: StartupMessages = StartupMessages { + title: "DBX could not start", + missing_runtime: "【DBX could not start】\nWebView2 is missing, so DBX cannot show its window.\n\nDownload the Windows 10 offline package from the DBX website and install it (the offline package bundles WebView2, no internet required). Click Yes to open the download page:\n{{url}}\n\nIf it still fails, send this log to the maintainer:\n{{log}}", + damaged_runtime: "【DBX could not start】\nThe WebView2 runtime is damaged, so DBX cannot show its window.\n\nDownload the Windows 10 offline package from the DBX website and install it (it bundles WebView2 and repairs it during setup). Click Yes to open the download page:\n{{url}}\n\nIf it still fails, send this log to the maintainer:\n{{log}}", + compatibility_failed: "【DBX could not start】\nDBX tried several startup compatibility methods, but the main window still did not appear.\n\nSend this log to the maintainer for diagnosis. Click Yes to open the log folder:\n{{log}}", + recovery_disabled: "【DBX could not start】\nThe main window did not appear and automatic compatibility retries are disabled in this environment.\n\nSend this log to the maintainer for diagnosis. Click Yes to open the log folder:\n{{log}}", +}; + +const ZH_CN: StartupMessages = StartupMessages { + title: "DBX 无法启动", + missing_runtime: "【DBX 无法启动】\n缺少 WebView2 运行时,DBX 无法显示主界面。\n\n请到 DBX 官网下载「Windows 10 离线版」安装(离线版自带 WebView2,无需联网),点“是”打开下载页:\n{{url}}\n\n如仍有问题,请把启动日志发给维护者:\n{{log}}", + damaged_runtime: "【DBX 无法启动】\nWebView2 运行时已损坏,DBX 无法显示主界面。\n\n请到 DBX 官网下载「Windows 10 离线版」重新安装(安装时会自动修复),点“是”打开下载页:\n{{url}}\n\n如仍有问题,请把启动日志发给维护者:\n{{log}}", + compatibility_failed: "【DBX 无法启动】\nDBX 已自动尝试多种兼容启动方式,主窗口仍未显示。\n\n请把启动日志发给维护者排查,点“是”打开日志所在文件夹:\n{{log}}", + recovery_disabled: "【DBX 无法启动】\n主窗口没有显示,且当前环境没有继续自动尝试兼容启动方式。\n\n请把启动日志发给维护者排查,点“是”打开日志所在文件夹:\n{{log}}", +}; + +const ZH_TW: StartupMessages = StartupMessages { + title: "DBX 無法啟動", + missing_runtime: "【DBX 無法啟動】\n缺少 WebView2 執行階段,DBX 無法顯示主視窗。\n\n請到 DBX 官網下載「Windows 10 離線版」安裝(離線版自帶 WebView2,無需連網),點「是」開啟下載頁:\n{{url}}\n\n若仍有問題,請把啟動日誌寄給維護者:\n{{log}}", + damaged_runtime: "【DBX 無法啟動】\nWebView2 執行階段已損壞,DBX 無法顯示主視窗。\n\n請到 DBX 官網下載「Windows 10 離線版」重新安裝(安裝時會自動修復),點「是」開啟下載頁:\n{{url}}\n\n若仍有問題,請把啟動日誌寄給維護者:\n{{log}}", + compatibility_failed: "【DBX 無法啟動】\nDBX 已自動嘗試多種相容啟動方式,主視窗仍未顯示。\n\n請把啟動日誌寄給維護者排查,點「是」開啟日誌所在資料夾:\n{{log}}", + recovery_disabled: "【DBX 無法啟動】\n主視窗沒有顯示,且目前環境沒有繼續自動嘗試相容啟動方式。\n\n請把啟動日誌寄給維護者排查,點「是」開啟日誌所在資料夾:\n{{log}}", +}; + +/// Normalize a BCP-47-ish locale tag and pick a catalog. Unknown locales fall +/// back to English, matching the existing native-UI behaviour. +pub(crate) fn messages_for_locale(locale: &str) -> &'static StartupMessages { + let normalized = locale.trim().to_ascii_lowercase().replace('_', "-"); + if normalized.starts_with("zh") { + // Traditional Chinese for Taiwan/Hong Kong/Macau and explicit Hant tags; + // everything else Chinese is Simplified. + if normalized.contains("-tw") + || normalized.contains("-hk") + || normalized.contains("-mo") + || normalized.contains("hant") + { + return &ZH_TW; + } + return &ZH_CN; + } + &EN +} + +/// Catalog for the current system locale. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) fn current() -> &'static StartupMessages { + messages_for_locale(&sys_locale::get_locale().unwrap_or_default()) +} + +/// Direct link to the Windows 10 offline package for this build's version. The +/// offline package embeds the WebView2 installer, so a user with a missing or +/// damaged runtime only needs this one download. The URL pattern is the same one +/// the official release pipeline publishes (`DBX__x64-offline-setup.exe`). +pub(crate) fn offline_installer_url() -> String { + let version = env!("CARGO_PKG_VERSION"); + format!("https://dl.dbxio.com/releases/v{version}/DBX_{version}_x64-offline-setup.exe") +} + +#[cfg(test)] +mod tests { + use super::{messages_for_locale, offline_installer_url}; + + #[test] + fn chinese_locales_are_split_between_simplified_and_traditional() { + assert_eq!(messages_for_locale("zh-CN").title, "DBX 无法启动"); + assert_eq!(messages_for_locale("zh_CN").title, "DBX 无法启动"); + assert_eq!(messages_for_locale("zh-TW").title, "DBX 無法啟動"); + assert_eq!(messages_for_locale("zh-HK").title, "DBX 無法啟動"); + assert_eq!(messages_for_locale("zh-Hant").title, "DBX 無法啟動"); + } + + #[test] + fn unsupported_locales_fall_back_to_english() { + assert_eq!(messages_for_locale("ja").title, "DBX could not start"); + assert_eq!(messages_for_locale("").title, "DBX could not start"); + assert_eq!(messages_for_locale("de-DE").title, "DBX could not start"); + } + + #[test] + fn templates_render_both_placeholders() { + let messages = messages_for_locale("zh-CN"); + let rendered = + messages.render(messages.missing_runtime, r"C:\logs\startup.log", "https://example.test/setup.exe"); + assert!(rendered.contains(r"C:\logs\startup.log")); + assert!(rendered.contains("https://example.test/setup.exe")); + assert!(!rendered.contains("{{log}}")); + assert!(!rendered.contains("{{url}}")); + } + + #[test] + fn offline_installer_url_matches_the_published_asset_name() { + let version = env!("CARGO_PKG_VERSION"); + assert_eq!( + offline_installer_url(), + format!("https://dl.dbxio.com/releases/v{version}/DBX_{version}_x64-offline-setup.exe") + ); + } + + #[test] + fn every_catalog_has_no_untranslated_placeholders_missing() { + for messages in [&super::EN, &super::ZH_CN, &super::ZH_TW] { + for template in [ + messages.missing_runtime, + messages.damaged_runtime, + messages.compatibility_failed, + messages.recovery_disabled, + ] { + assert!(template.contains("{{log}}")); + assert!(!messages.title.is_empty()); + } + } + } +} diff --git a/src-tauri/src/startup_recovery.rs b/src-tauri/src/startup_recovery.rs index 6f55d65f84..1aa62ecbe6 100644 --- a/src-tauri/src/startup_recovery.rs +++ b/src-tauri/src/startup_recovery.rs @@ -1,10 +1,42 @@ +//! Windows startup diagnostics and the ordered startup-compatibility ladder. +//! +//! Some Windows 10 enterprise machines (360 安全卫士 / 奇安信 / 卡巴斯基 and other +//! security products) block WebView2 from creating its browser process. The Tauri +//! event loop then never runs (or starts but the main window stays invisible with +//! `Runtime(FailedToReceiveMessage)` window queries) and the user sees nothing. +//! +//! The previous behaviour was a single retry with one "enterprise compatibility" +//! profile. Norton worked with it; 360 / QiAnXin / Kaspersky still did not. This +//! module now owns a deterministic *ladder* of startup profiles: every profile is +//! a set of WebView2 environment overrides (user data folder, browser arguments, +//! warm-up preflight, scan delay, elevation, relocated executable), and a failed +//! attempt relaunches the executable with the next profile through environment +//! variables (see [`CompatProfile`]). The last successful profile is recorded so +//! a later launch can go straight to it (after asking the user once), and every +//! attempt appends structured evidence to `startup.log` — the file users send to +//! the maintainer. +//! +//! Design constraints: +//! - The standard startup path must stay byte-for-byte equivalent in behaviour: +//! no environment overrides, no dialogs, no retries unless the main window +//! never becomes visible. +//! - A profile only changes WebView2 environment variables *before* Tauri builds +//! the webview, because wry reads them at environment-creation time. The +//! vendored wry patch makes `WEBVIEW2_USER_DATA_FOLDER` and +//! `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` effective (upstream wry always set +//! explicit options, which made the variables no-ops). +//! - The chain must be bounded: a hard cap on attempts, and a one-shot guard for +//! the elevated profile so UAC cannot loop. +//! - Diagnostics in `startup.log` are deliberately path-class only (no usernames, +//! no full user-profile paths) because users email the file. + use std::collections::VecDeque; use std::ffi::OsString; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering}; use std::sync::{Condvar, LazyLock, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; use tauri::Manager; const STARTUP_LOG_FILE: &str = "startup.log"; @@ -12,20 +44,274 @@ const STARTUP_LOG_FILE: &str = "startup.log"; const RUNTIME_RECOVERY_LOG_FILE: &str = "webview2-recovery.log"; const STARTUP_LOG_DIR_ENV: &str = "DBX_STARTUP_LOG_DIR"; const KEEP_STARTUP_LOG_ENV: &str = "DBX_KEEP_STARTUP_LOG"; -#[cfg(target_os = "windows")] const NO_SANDBOX_ENV: &str = "DBX_WEBVIEW2_NO_SANDBOX"; -#[cfg(target_os = "windows")] +/// Set on every compatibility child: legacy token for "this is a recovery attempt". +// Kept unconditional: the shared browser-argument preparation code (also built +// for host-side unit tests) reads it; it is only meaningful for Win7 targets. const DISABLE_WIN7_CSS_COLOR4_ENV: &str = "DBX_DISABLE_WIN7_CSS_COLOR4"; const RECOVERY_ATTEMPT_ENV: &str = "DBX_STARTUP_COMPAT_RECOVERY"; +/// Parent process id, used to wait for the single-instance mutex handoff. const RECOVERY_PARENT_PID_ENV: &str = "DBX_STARTUP_COMPAT_PARENT_PID"; +/// The profile index executed by this process. `0`/unset = standard startup. +const COMPAT_PROFILE_ENV: &str = "DBX_STARTUP_COMPAT_PROFILE"; +/// Comma separated profile indexes already attempted in this chain. +const COMPAT_HISTORY_ENV: &str = "DBX_STARTUP_COMPAT_HISTORY"; +/// Set once the elevated profile has been attempted, so UAC cannot loop. +const COMPAT_ELEVATED_ENV: &str = "DBX_STARTUP_COMPAT_ELEVATED"; +/// Captured once per chain: the user's original WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS. +/// Each profile rebuilds the effective argument list from this base instead of +/// accumulating flags from the previous attempt. +const COMPAT_BASE_ARGS_ENV: &str = "DBX_STARTUP_COMPAT_BASE_ARGS"; const DISABLE_ENTERPRISE_COMPAT_ENV: &str = "DBX_DISABLE_ENTERPRISE_COMPAT"; +const DISABLE_STARTUP_COMPAT_ENV: &str = "DBX_DISABLE_STARTUP_COMPAT"; +/// Optional test hook: overrides the standard-mode watchdog delay in seconds. +const WATCHDOG_SECS_ENV: &str = "DBX_STARTUP_WATCHDOG_SECS"; +/// Optional test hook: how long to keep waiting after a failed window probe +/// before escalating (gives a slow WebView2 creation a last chance). +const FAST_FAIL_GRACE_MS_ENV: &str = "DBX_STARTUP_COMPAT_FAST_FAIL_GRACE_MS"; + const WINDOWS_APP_DATA_DIR_NAME: &str = "com.dbx.app"; const COMPATIBILITY_MARKER_FILE: &str = "webview2-enterprise-compat.enabled"; const COMPATIBILITY_PROFILE_DIR: &str = "webview2-enterprise-compat"; -const STARTUP_LOG_BUFFER_CAPACITY: usize = 256; +const COMPATIBILITY_TEMP_PROFILE_DIR: &str = "dbx-webview2-compat"; +const STARTUP_LOG_BUFFER_CAPACITY: usize = 512; +/// Standard startup: the app may legitimately take a while on cold/slow disks. const STARTUP_WATCHDOG_DELAY: Duration = Duration::from_secs(60); +/// Compatibility attempts are experiments: fail fast and move on. +const COMPAT_WATCHDOG_DELAY: Duration = Duration::from_secs(30); +const FAST_FAIL_GRACE: Duration = Duration::from_secs(6); #[cfg(target_os = "windows")] const RECOVERY_PARENT_WAIT_TIMEOUT: Duration = Duration::from_secs(10); +#[cfg(target_os = "windows")] +const PREFLIGHT_CHILD_TIMEOUT: Duration = Duration::from_secs(45); + +/// One startup-compatibility profile. Every field must be pure data so the +/// ladder is testable without Windows/WebView2 (`cargo test` on any host). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CompatUserData { + /// Keep whatever WebView2/wry would use (standard behaviour). + Default, + /// Isolated folder: %LOCALAPPDATA%\com.dbx.app\webview2-enterprise-compat + Isolated, + /// Per-user temp folder: %TEMP%\dbx-webview2-compat + Temp, +} + +#[derive(Clone, Copy, Debug)] +struct CompatProfile { + index: u8, + /// Stable id used in logs and the persisted marker. + id: &'static str, + label_zh: &'static str, + label_en: &'static str, + user_data: CompatUserData, + /// Extra Chromium switches merged into WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS. + browser_args: &'static str, + /// Sleep before Tauri builds the webview (lets a security product finish its + /// first-run scan of msedgewebview2.exe / dbx.exe). + start_delay: Duration, + /// Create and release a WebView2 environment in a throwaway child process + /// before the real window is created. The scan/trust side effect is the point. + warmup: bool, + /// Relaunch through ShellExecuteW "runas" (UAC). Some AV products do not + /// block browser child processes of elevated applications. + elevated: bool, + /// Apply the per-user AppCompat "Windows 8" layer to msedgewebview2.exe + /// before the webview is created. Field evidence: this workaround unblocks + /// WebView2 startup when an injected DLL / code-integrity hook kills the + /// renderer (see `docs/win-av-compat-research.md`, WebView2Feedback #4094). + /// Reversible; removed again on final failure or when the user declines to + /// keep the working compatibility mode. + appcompat_win8: bool, + /// Copy the executable to %TEMP% and relaunch from there: defeats path-based + /// AV rules aimed at the install directory. + temp_copy: bool, + /// Watchdog budget for this attempt (including start_delay is added on top). + watchdog: Duration, +} + +/// Ordered ladder. The first profile is the old enterprise compatibility mode; +/// later profiles add progressively more aggressive workarounds. Profiles are +/// only ever spawned after a detected startup failure, so their risk is bounded +/// to machines where the app cannot start at all. +/// +/// Ordering rationale (from `docs/win-av-compat-research.md`): +/// - Cheapest/most likely AV-interference fixes first (sandbox/GPU/process flags). +/// - `RendererCodeIntegrity` can only be disabled on WebView2 < 118; on newer +/// runtimes the flag is a no-op, so it is bundled into later flag profiles +/// instead of a dedicated attempt. +/// - `--no-zygote`, `--disable-dev-shm-usage` and `--user-data-dir` are Linux / +/// non-WebView2 mechanisms and are deliberately absent. +/// - `--disable-breakpad` / `--disable-crash-reporter` are harmful on WebView2 +/// (Microsoft requires Crashpad for content to load) and are absent. +/// - Each profile gets its own WebView2 user data directory: reusing one folder +/// with different environment options can fail with ERROR_INVALID_STATE. +const COMPAT_PROFILES: &[CompatProfile] = &[ + CompatProfile { + index: 1, + id: "isolated-no-sandbox", + label_zh: "隔离数据目录 + 关闭沙箱和 GPU 加速", + label_en: "isolated user data + sandbox/GPU off", + user_data: CompatUserData::Isolated, + browser_args: "--no-sandbox --disable-gpu", + start_delay: Duration::ZERO, + warmup: false, + elevated: false, + appcompat_win8: false, + temp_copy: false, + watchdog: COMPAT_WATCHDOG_DELAY, + }, + CompatProfile { + index: 2, + id: "renderer-code-integrity-off", + label_zh: "关闭 WebView2 渲染器代码完整性校验(仅旧版运行时有效)", + label_en: "WebView2 RendererCodeIntegrity off (old runtimes only)", + user_data: CompatUserData::Isolated, + browser_args: "--no-sandbox --disable-gpu \ + --disable-features=RendererCodeIntegrity,msSmartScreenProtection", + start_delay: Duration::ZERO, + warmup: false, + elevated: false, + appcompat_win8: false, + temp_copy: false, + watchdog: COMPAT_WATCHDOG_DELAY, + }, + CompatProfile { + index: 3, + id: "subprocess-sandbox-off", + label_zh: "关闭 GPU 沙箱与首次运行/后台网络", + label_en: "GPU/utility sandbox off, first-run and background network off", + user_data: CompatUserData::Isolated, + browser_args: "--no-sandbox --disable-gpu --disable-gpu-sandbox --no-first-run \ + --disable-component-update --disable-background-networking \ + --disable-features=RendererCodeIntegrity,msSmartScreenProtection", + start_delay: Duration::ZERO, + warmup: false, + elevated: false, + appcompat_win8: false, + temp_copy: false, + watchdog: COMPAT_WATCHDOG_DELAY, + }, + CompatProfile { + index: 4, + id: "in-process-gpu", + label_zh: "GPU 进程内联(规避 GPU 子进程被拦截)", + label_en: "in-process GPU (avoid a blocked GPU child process)", + user_data: CompatUserData::Isolated, + browser_args: "--no-sandbox --disable-gpu-sandbox --in-process-gpu \ + --disable-features=RendererCodeIntegrity,msSmartScreenProtection", + start_delay: Duration::ZERO, + warmup: false, + elevated: false, + appcompat_win8: false, + temp_copy: false, + watchdog: COMPAT_WATCHDOG_DELAY, + }, + CompatProfile { + index: 5, + id: "temp-user-data", + label_zh: "临时目录 WebView2 数据 + 关闭沙箱", + label_en: "temp WebView2 user data + sandbox off", + user_data: CompatUserData::Temp, + browser_args: "--no-sandbox --disable-gpu --disable-gpu-sandbox --no-first-run \ + --disable-features=RendererCodeIntegrity,msSmartScreenProtection", + start_delay: Duration::ZERO, + warmup: false, + elevated: false, + appcompat_win8: false, + temp_copy: false, + watchdog: COMPAT_WATCHDOG_DELAY, + }, + CompatProfile { + index: 6, + id: "av-scan-delay", + label_zh: "等待杀软首次扫描完成(20 秒)后启动", + label_en: "wait 20s for AV first-run scan", + user_data: CompatUserData::Isolated, + browser_args: "--no-sandbox --disable-gpu \ + --disable-features=RendererCodeIntegrity,msSmartScreenProtection", + start_delay: Duration::from_secs(20), + warmup: false, + elevated: false, + appcompat_win8: false, + temp_copy: false, + watchdog: Duration::from_secs(45), + }, + CompatProfile { + index: 7, + id: "warmup-preflight", + label_zh: "先预热启动 WebView2 再拉起主窗口", + label_en: "warm up WebView2 in a helper process first", + user_data: CompatUserData::Isolated, + browser_args: "--no-sandbox --disable-gpu --disable-gpu-sandbox --no-first-run \ + --disable-features=RendererCodeIntegrity,msSmartScreenProtection", + start_delay: Duration::ZERO, + warmup: true, + elevated: false, + appcompat_win8: false, + temp_copy: false, + watchdog: Duration::from_secs(40), + }, + CompatProfile { + index: 8, + id: "appcompat-win8", + label_zh: "给 WebView2 子进程设置 Windows 8 兼容模式", + label_en: "AppCompat Windows 8 layer for msedgewebview2.exe", + user_data: CompatUserData::Isolated, + browser_args: "--no-sandbox --disable-gpu --disable-gpu-sandbox --no-first-run \ + --disable-features=RendererCodeIntegrity,msSmartScreenProtection", + start_delay: Duration::ZERO, + warmup: false, + elevated: false, + appcompat_win8: true, + temp_copy: false, + watchdog: Duration::from_secs(40), + }, + CompatProfile { + index: 9, + id: "elevated-relaunch", + label_zh: "以管理员权限重新启动(会弹 UAC)", + label_en: "relaunch elevated (UAC prompt)", + user_data: CompatUserData::Isolated, + browser_args: "--no-sandbox --disable-gpu --disable-gpu-sandbox --no-first-run \ + --disable-features=RendererCodeIntegrity,msSmartScreenProtection", + start_delay: Duration::ZERO, + warmup: false, + elevated: true, + appcompat_win8: false, + temp_copy: false, + watchdog: Duration::from_secs(45), + }, + CompatProfile { + index: 10, + id: "temp-relocated", + label_zh: "从临时目录副本启动(规避安装目录规则)", + label_en: "launch a copy from %TEMP%", + user_data: CompatUserData::Isolated, + browser_args: "--no-sandbox --disable-gpu --disable-gpu-sandbox --no-first-run \ + --disable-features=RendererCodeIntegrity,msSmartScreenProtection", + start_delay: Duration::ZERO, + warmup: false, + elevated: false, + appcompat_win8: false, + temp_copy: true, + watchdog: Duration::from_secs(45), + }, + CompatProfile { + index: 11, + id: "single-process-experimental", + label_zh: "单进程渲染模式(实验性,WebView2 可能不支持)", + label_en: "single-process renderer (experimental, may be unsupported)", + user_data: CompatUserData::Isolated, + browser_args: "--no-sandbox --disable-gpu --single-process", + start_delay: Duration::ZERO, + warmup: false, + elevated: false, + appcompat_win8: false, + temp_copy: false, + watchdog: COMPAT_WATCHDOG_DELAY, + }, +]; #[derive(Default)] struct StartupProbeState { @@ -36,9 +322,10 @@ struct StartupProbeState { static STARTUP_PROBE_STATE: LazyLock> = LazyLock::new(|| Mutex::new(StartupProbeState::default())); static STARTUP_PROBE_ACTIVE: AtomicBool = AtomicBool::new(false); -static RECOVERY_ATTEMPT: AtomicBool = AtomicBool::new(false); -static ENTERPRISE_COMPAT: AtomicBool = AtomicBool::new(false); -static ENTERPRISE_COMPAT_DISABLED: AtomicBool = AtomicBool::new(false); +static COMPAT_DISABLED: AtomicBool = AtomicBool::new(false); +static MARKER_MODE: AtomicBool = AtomicBool::new(false); +static CURRENT_PROFILE: AtomicU8 = AtomicU8::new(0); +static WINDOW_PROBE_FAILED: AtomicBool = AtomicBool::new(false); static RUN_EVENT_COUNT: AtomicUsize = AtomicUsize::new(0); static FRONTEND_READY_SIGNAL: LazyLock<(Mutex, Condvar)> = LazyLock::new(|| (Mutex::new(false), Condvar::new())); @@ -46,6 +333,10 @@ fn env_flag(name: &str) -> bool { matches!(std::env::var(name).as_deref(), Ok("1")) } +fn env_duration_ms(name: &str, default: Duration) -> Duration { + std::env::var(name).ok().and_then(|value| value.parse::().ok()).map(Duration::from_millis).unwrap_or(default) +} + fn startup_log_dir_from_inputs( target_os: &str, explicit_dir: Option, @@ -84,14 +375,7 @@ fn compatibility_marker_path() -> Option { compatibility_marker_path_from_appdata(std::env::var_os("APPDATA")) } -fn compatibility_marker_contents(version: &str) -> String { - format!("version={version}\nmode=isolated-profile-no-sandbox-disable-gpu\n") -} - -fn compatibility_marker_matches_version(path: &Path, version: &str) -> bool { - std::fs::read_to_string(path).is_ok_and(|contents| contents == compatibility_marker_contents(version)) -} - +#[cfg_attr(not(test), allow(dead_code))] fn compatibility_profile_path_from_inputs( local_appdata: Option, windows_appdata: Option, @@ -105,23 +389,168 @@ fn compatibility_profile_path_from_inputs( (None, "unavailable") } -#[cfg(target_os = "windows")] -fn compatibility_profile_path() -> (Option, &'static str) { - compatibility_profile_path_from_inputs(std::env::var_os("LOCALAPPDATA"), std::env::var_os("APPDATA")) -} - -fn resolve_compatibility_mode(recovery_requested: bool, marker_enabled: bool, disabled: bool) -> (bool, bool) { - if disabled { - (false, false) - } else { - (recovery_requested, recovery_requested || marker_enabled) +/// Returns the folder a profile wants for WebView2 user data plus a +/// log-safe source class (never the actual user path). +#[cfg_attr(not(test), allow(dead_code))] +fn compat_profile_user_data_path( + profile: Option<&CompatProfile>, + local_appdata: Option, + windows_appdata: Option, + temp_dir: Option, +) -> (Option, &'static str, bool) { + match profile.map(|profile| profile.user_data).unwrap_or(CompatUserData::Default) { + CompatUserData::Default => (None, "default", false), + CompatUserData::Isolated => { + let (path, source) = compatibility_profile_path_from_inputs(local_appdata, windows_appdata); + (path.map(|path| path.join(profile_user_data_suffix(profile))), source, true) + } + CompatUserData::Temp => { + let path = temp_dir.filter(|value| !value.is_empty()).map(PathBuf::from); + ( + path.map(|dir| dir.join(COMPATIBILITY_TEMP_PROFILE_DIR).join(profile_user_data_suffix(profile))), + "temp", + true, + ) + } } } -fn compatibility_profile_ready_record(profile_source: &str) -> String { - format!("enterprise compatibility profile ready source={profile_source}") +/// Every profile uses its own WebView2 user data directory: reusing one folder +/// with different environment options can fail with ERROR_INVALID_STATE, and a +/// stale profile from a crashed attempt must not poison the next one. +fn profile_user_data_suffix(profile: Option<&CompatProfile>) -> String { + match profile { + Some(profile) => format!("profile-{}", profile.index), + None => "profile-standard".to_string(), + } } +#[cfg(target_os = "windows")] +fn compat_profile_user_data() -> (Option, &'static str, bool) { + compat_profile_user_data_path( + current_profile(), + std::env::var_os("LOCALAPPDATA"), + std::env::var_os("APPDATA"), + std::env::var_os("TEMP"), + ) +} + +fn compat_profile(index: u8) -> Option<&'static CompatProfile> { + COMPAT_PROFILES.iter().find(|profile| profile.index == index) +} + +fn current_profile() -> Option<&'static CompatProfile> { + compat_profile(CURRENT_PROFILE.load(Ordering::Acquire)) +} + +fn current_profile_index() -> u8 { + CURRENT_PROFILE.load(Ordering::Acquire) +} + +fn max_compat_profile_index() -> u8 { + COMPAT_PROFILES.last().map(|profile| profile.index).unwrap_or(0) +} + +fn parse_profile_index(value: Option<&str>) -> Option { + value.and_then(|value| value.trim().parse::().ok()).filter(|index| *index > 0) +} + +fn parse_history(history: &str) -> Vec { + history + .split(',') + .filter_map(|entry| entry.trim().parse::().ok()) + .filter(|index| *index > 0 && compat_profile(*index).is_some()) + .collect() +} + +fn format_history(history: &str) -> String { + let indexes = parse_history(history); + if indexes.is_empty() { + return "none".to_string(); + } + indexes + .iter() + .map(|index| { + let id = compat_profile(*index).map(|profile| profile.id).unwrap_or("unknown"); + format!("{index}:{id}") + }) + .collect::>() + .join(", ") +} + +fn appended_history(history: &str, index: u8) -> String { + let mut indexes = parse_history(history); + if index > 0 && !indexes.contains(&index) { + indexes.push(index); + } + indexes.iter().map(u8::to_string).collect::>().join(",") +} + +fn compatibility_marker_contents(version: &str, profile: &CompatProfile) -> String { + format!("version={version}\nmode=startup-compat-profile\nprofile={}\nprofile_id={}\n", profile.index, profile.id) +} + +fn parse_compatibility_marker(contents: &str, version: &str) -> Option { + let mut marker_version = None; + let mut mode = None; + let mut profile = None; + for line in contents.lines() { + let Some((key, value)) = line.split_once('=') else { continue }; + match key.trim() { + "version" => marker_version = Some(value.trim()), + "mode" => mode = Some(value.trim()), + "profile" => profile = value.trim().parse::().ok(), + _ => {} + } + } + if marker_version != Some(version) { + return None; + } + match mode { + Some("startup-compat-profile") => profile.filter(|index| compat_profile(*index).is_some()), + // Migration: the pre-ladder marker only meant "the old enterprise + // compatibility mode worked", which is profile 1 of the ladder. + Some("isolated-profile-no-sandbox-disable-gpu") => Some(1), + _ => None, + } +} + +#[cfg(test)] +#[cfg_attr(not(test), allow(dead_code))] +fn compatibility_marker_contains_profile_mismatch(contents: &str, version: &str, profile: u8) -> bool { + match parse_compatibility_marker(contents, version) { + Some(marker_profile) => marker_profile != profile, + None => true, + } +} + +#[cfg(test)] +#[cfg_attr(not(test), allow(dead_code))] +fn expected_marker_mismatch(contents: &str, version: &str, profile: u8) -> bool { + compatibility_marker_contains_profile_mismatch(contents, version, profile) +} + +fn read_compatibility_marker(version: &str) -> Option { + let path = compatibility_marker_path()?; + let contents = std::fs::read_to_string(path).ok()?; + parse_compatibility_marker(&contents, version) +} + +fn write_compatibility_marker(profile: &CompatProfile) -> Result<(), String> { + let path = compatibility_marker_path().ok_or_else(|| "APPDATA is unavailable".to_string())?; + if !ensure_parent_dir(&path) { + return Err("failed to create compatibility marker directory".to_string()); + } + std::fs::write(&path, compatibility_marker_contents(env!("CARGO_PKG_VERSION"), profile)) + .map_err(|error| error.to_string()) +} + +fn compatibility_profile_ready_record(profile: Option<&CompatProfile>, source: &str) -> String { + let id = profile.map(|profile| profile.id).unwrap_or("standard"); + format!("compat profile user data ready source={source} profile={id}") +} + +#[cfg(test)] fn configure_recovery_child(command: &mut std::process::Command, parent_pid: u32) { command.env(RECOVERY_ATTEMPT_ENV, "1").env(RECOVERY_PARENT_PID_ENV, parent_pid.to_string()); } @@ -136,7 +565,7 @@ fn wait_for_recovery_parent_exit() -> &'static str { let Some(parent_pid) = parent_pid.filter(|pid| *pid != 0) else { return "parent_pid_unavailable"; }; - let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, false.into(), parent_pid) }; + let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, parent_pid) }; if handle.is_null() { return "parent_already_exited"; } @@ -196,60 +625,239 @@ fn append_persisted_record(message: impl AsRef) { } pub(crate) fn initialize() { - let enterprise_compat_disabled = env_flag(DISABLE_ENTERPRISE_COMPAT_ENV); + let enterprise_compat_disabled = env_flag(DISABLE_ENTERPRISE_COMPAT_ENV) || env_flag(DISABLE_STARTUP_COMPAT_ENV); + COMPAT_DISABLED.store(enterprise_compat_disabled, Ordering::Release); + let recovery_requested = env_flag(RECOVERY_ATTEMPT_ENV); + let env_profile = parse_profile_index(std::env::var(COMPAT_PROFILE_ENV).ok().as_deref()); + let marker_profile = + if enterprise_compat_disabled { None } else { read_compatibility_marker(env!("CARGO_PKG_VERSION")) }; + let (profile_index, marker_mode) = if enterprise_compat_disabled { + (0, false) + } else if let Some(index) = env_profile { + (index, false) + } else if recovery_requested { + // Legacy recovery token (no profile index): the old enterprise mode was + // profile 1 of the new ladder. + (1, false) + } else if let Some(index) = marker_profile { + (index, true) + } else { + (0, false) + }; + let profile_index = if compat_profile(profile_index).is_some() { profile_index } else { 0 }; + let marker_mode = marker_mode && compat_profile(profile_index).is_some(); + let history = std::env::var(COMPAT_HISTORY_ENV).unwrap_or_default(); + // The parent owns tauri-plugin-single-instance's mutex. Wait for process exit // before building Tauri so the recovery child can acquire the same mutex. let parent_handoff = (recovery_requested && !enterprise_compat_disabled).then(wait_for_recovery_parent_exit); - let marker_path = compatibility_marker_path(); - let marker_enabled = !enterprise_compat_disabled - && marker_path - .as_deref() - .is_some_and(|path| compatibility_marker_matches_version(path, env!("CARGO_PKG_VERSION"))); - let (recovery_attempt, enterprise_compat) = - resolve_compatibility_mode(recovery_requested, marker_enabled, enterprise_compat_disabled); - if !marker_enabled && !enterprise_compat_disabled { - if let Some(path) = marker_path.filter(|path| path.is_file()) { + if !recovery_requested { + if let Some(path) = startup_log_path() { let _ = std::fs::remove_file(path); } } - - if !recovery_attempt { - if let Some(path) = startup_log_path() { - let _ = std::fs::remove_file(path); + if !enterprise_compat_disabled { + if let Some(path) = compatibility_marker_path().filter(|path| path.is_file()) { + let has_valid_marker = marker_profile.is_some() || marker_mode; + if !has_valid_marker { + let _ = std::fs::remove_file(path); + } } } { let mut state = STARTUP_PROBE_STATE.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); *state = StartupProbeState { - persistent: recovery_attempt, + persistent: recovery_requested, lines: VecDeque::with_capacity(STARTUP_LOG_BUFFER_CAPACITY), }; } STARTUP_PROBE_ACTIVE.store(true, Ordering::Release); - RECOVERY_ATTEMPT.store(recovery_attempt, Ordering::Release); - ENTERPRISE_COMPAT.store(enterprise_compat, Ordering::Release); - ENTERPRISE_COMPAT_DISABLED.store(enterprise_compat_disabled, Ordering::Release); + CURRENT_PROFILE.store(profile_index, Ordering::Release); + MARKER_MODE.store(marker_mode, Ordering::Release); + WINDOW_PROBE_FAILED.store(false, Ordering::Release); RUN_EVENT_COUNT.store(0, Ordering::Release); *FRONTEND_READY_SIGNAL.0.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = false; install_panic_hook(); + let profile = current_profile(); record(format!( - "process start version={} os={} arch={} recovery_attempt={} compatibility_marker={} enterprise_compat={} enterprise_compat_disabled={}", + "process start version={} os={} arch={} recovery_attempt={} compat_profile={} profile_id={} marker_mode={} enterprise_compat_disabled={} history={}", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH, - recovery_attempt, - marker_enabled, - enterprise_compat, - enterprise_compat_disabled + recovery_requested, + profile_index, + profile.map(|profile| profile.id).unwrap_or("standard"), + marker_mode, + enterprise_compat_disabled, + format_history(&history) )); + if marker_mode { + record(format!( + "startup compatibility marker says profile {} worked last time; starting directly with it", + profile_index + )); + } if let Some(parent_handoff) = parent_handoff { record(format!("single-instance recovery handoff status={parent_handoff}")); } - configure_webview2_compatibility(enterprise_compat); + for line in prepare_browser_environment(profile) { + record(line); + } + if let Some(profile) = profile { + record(format!( + "compat profile settings index={} id={} label_zh={} label_en={} user_data={:?} watchdog_secs={} start_delay_ms={} warmup={} elevated={} temp_copy={}", + profile.index, + profile.id, + profile.label_zh, + profile.label_en, + profile.user_data, + profile.watchdog.as_secs(), + profile.start_delay.as_millis(), + profile.warmup, + profile.elevated, + profile.temp_copy + )); + } +} + +fn append_webview2_argument_to_value(mut args: String, argument: &str) -> String { + if args.split_whitespace().any(|value| value == argument) { + return args; + } + if !args.is_empty() { + args.push(' '); + } + args.push_str(argument); + args +} + +fn webview2_compatibility_arguments(mut args: String, enterprise_compat: bool, manual_no_sandbox: bool) -> String { + if enterprise_compat || manual_no_sandbox { + args = append_webview2_argument_to_value(args, "--no-sandbox"); + } + if enterprise_compat { + args = append_webview2_argument_to_value(args, "--disable-gpu"); + } + args +} + +/// Captures the user's original browser arguments once, then merges the manual +/// `DBX_WEBVIEW2_NO_SANDBOX` flag and the current profile's switches on top of +/// that base. Returns log lines (never containing user paths). +fn prepare_browser_environment(profile: Option<&CompatProfile>) -> Vec { + let mut lines = Vec::new(); + if std::env::var_os(COMPAT_BASE_ARGS_ENV).is_none() { + let base = std::env::var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS").unwrap_or_default(); + std::env::set_var(COMPAT_BASE_ARGS_ENV, &base); + } + let base = std::env::var(COMPAT_BASE_ARGS_ENV).unwrap_or_default(); + let manual_no_sandbox = env_flag(NO_SANDBOX_ENV); + // Preserve upstream's Win7 CSSColor4 handling: it edits the same argument + // list the compatibility profiles extend, so it has to run on the base args + // before the profile switches are appended. + let win7_css_disabled = env_flag(DISABLE_WIN7_CSS_COLOR4_ENV); + let base = crate::webview2_legacy_css::configure(&base, cfg!(target_vendor = "win7"), win7_css_disabled); + let mut args = webview2_compatibility_arguments(base.clone(), profile.is_some(), manual_no_sandbox); + let profile_args = profile.map(|profile| profile.browser_args).unwrap_or_default(); + for argument in profile_args.split_whitespace() { + args = append_webview2_argument_to_value(args, argument); + } + if cfg!(target_vendor = "win7") { + lines.push(format!( + "Win7 WebView2 CSSColor4 requested={} opt_out={win7_css_disabled}", + crate::webview2_legacy_css::requested(&args) + )); + } + if !args.trim().is_empty() { + std::env::set_var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", args.trim()); + } + + match profile.map(|profile| profile.user_data).unwrap_or(CompatUserData::Default) { + CompatUserData::Default => { + // Standard startup: do not touch the user data folder. Profiles that + // explicitly ask for Default remove a value inherited from an + // earlier attempt in the same chain. + if profile.is_some() { + std::env::remove_var("WEBVIEW2_USER_DATA_FOLDER"); + } + } + CompatUserData::Isolated | CompatUserData::Temp => { + #[cfg(target_os = "windows")] + let (path, source) = { + let (path, source, _) = compat_profile_user_data(); + (path, source) + }; + #[cfg(not(target_os = "windows"))] + let (path, source) = (None::, "unsupported_platform"); + match path { + Some(path) => match std::fs::create_dir_all(&path) { + Ok(()) => { + std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", &path); + lines.push(compatibility_profile_ready_record(profile, source)); + } + Err(_error) => lines.push(format!( + "compat profile user data folder unavailable source={source} error_class=create_failed" + )), + }, + None => lines.push(format!( + "compat profile user data folder unavailable source={source} error_class=missing_env" + )), + } + } + } + + if profile.is_some() || manual_no_sandbox || !base.is_empty() { + lines.push(format!( + "compat browser args prepared manual_no_sandbox={manual_no_sandbox} profile_args_present={} effective={}", + !profile_args.is_empty(), + std::env::var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS").unwrap_or_default() + )); + } + lines +} + +/// Runs the pre-Tauri part of the active compatibility profile: scan delay and +/// WebView2 warm-up preflight. Called from `run()` before the Tauri builder +/// creates the webview; a no-op in standard mode and on non-Windows hosts. +pub(crate) fn run_profile_pre_tauri_hooks() { + #[cfg(target_os = "windows")] + { + let Some(profile) = current_profile() else { return }; + if profile.index == 0 { + return; + } + if !profile.start_delay.is_zero() { + record(format!("compat profile waiting {}ms before tauri build", profile.start_delay.as_millis())); + std::thread::sleep(profile.start_delay); + record("compat profile scan delay finished"); + } + if profile.appcompat_win8 { + record("compat profile applying AppCompat Windows 8 layer to msedgewebview2.exe"); + match crate::windows_appcompat::set_win8_compat_layer() { + Ok(summary) => record(summary), + Err(error) => record(format!("appcompat win8 profile could not apply layer: {error}")), + } + } + if profile.warmup { + record( + "compat profile warm-up preflight starting (child process creates and releases a WebView2 environment)", + ); + let outcome = crate::webview2_preflight::spawn_preflight_child(PREFLIGHT_CHILD_TIMEOUT); + record(format!( + "compat warm-up preflight finished spawned={} ok={} hresult={} elapsed_ms={} timed_out={} exit_code={:?}", + outcome.spawned, + outcome.ok, + outcome.hresult.as_deref().unwrap_or("-"), + outcome.elapsed_ms, + outcome.timed_out, + outcome.exit_code + )); + } + } } pub(crate) fn record(message: impl AsRef) { @@ -311,98 +919,93 @@ fn install_panic_hook() { })); } -fn append_webview2_argument_to_value(mut args: String, argument: &str) -> String { - if args.split_whitespace().any(|value| value == argument) { - return args; - } - if !args.is_empty() { - args.push(' '); - } - args.push_str(argument); - args -} - -fn webview2_compatibility_arguments(mut args: String, enterprise_compat: bool, manual_no_sandbox: bool) -> String { - if enterprise_compat || manual_no_sandbox { - args = append_webview2_argument_to_value(args, "--no-sandbox"); - } - if enterprise_compat { - args = append_webview2_argument_to_value(args, "--disable-gpu"); - } - args -} - -#[cfg(target_os = "windows")] -fn configure_webview2_compatibility(enterprise_compat: bool) { - let manual_no_sandbox = env_flag(NO_SANDBOX_ENV); - if enterprise_compat { - let (profile_path, profile_source) = compatibility_profile_path(); - match profile_path { - Some(path) => match std::fs::create_dir_all(&path) { - Ok(()) => { - std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", &path); - record(compatibility_profile_ready_record(profile_source)); - } - Err(error) => record(format!("failed to create enterprise compatibility profile: {error}")), - }, - None => record("enterprise compatibility profile unavailable: LOCALAPPDATA and APPDATA are missing"), - } - } - let existing_args = std::env::var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS").unwrap_or_default(); - let win7_css_disabled = env_flag(DISABLE_WIN7_CSS_COLOR4_ENV); - let args = crate::webview2_legacy_css::configure(&existing_args, cfg!(target_vendor = "win7"), win7_css_disabled); - let args = webview2_compatibility_arguments(args, enterprise_compat, manual_no_sandbox); - if args != existing_args { - std::env::set_var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", &args); - } - if cfg!(target_vendor = "win7") { - record(format!( - "Win7 WebView2 CSSColor4 requested={} opt_out={win7_css_disabled}", - crate::webview2_legacy_css::requested(&args) - )); - } - if enterprise_compat || manual_no_sandbox { - record(format!("WebView2 no-sandbox enabled enterprise_compat={enterprise_compat} manual={manual_no_sandbox}")); - } - if enterprise_compat { - record("WebView2 GPU acceleration disabled for enterprise compatibility recovery"); - } -} - -#[cfg(not(target_os = "windows"))] -fn configure_webview2_compatibility(_enterprise_compat: bool) {} - pub(crate) fn record_run_event() { if STARTUP_PROBE_ACTIVE.load(Ordering::Acquire) { RUN_EVENT_COUNT.fetch_add(1, Ordering::Relaxed); } } -fn probe_snapshot() -> (bool, bool, bool, usize) { - ( - STARTUP_PROBE_ACTIVE.load(Ordering::Acquire), - ENTERPRISE_COMPAT.load(Ordering::Acquire), - ENTERPRISE_COMPAT_DISABLED.load(Ordering::Acquire), - RUN_EVENT_COUNT.load(Ordering::Acquire), - ) +/// Called by `lib.rs` after each attempt to show the main window. A failing +/// visibility query means the WebView2 window never became usable; arming the +/// fast-fail signal lets the watchdog escalate after a short grace period instead +/// of waiting the full watchdog budget. +pub(crate) fn report_window_visibility_probe(main_window_visible: bool) { + if main_window_visible || !STARTUP_PROBE_ACTIVE.load(Ordering::Acquire) { + return; + } + if !WINDOW_PROBE_FAILED.swap(true, Ordering::AcqRel) { + record("main window visibility probe failed; arming fast startup recovery"); + } else { + record("main window visibility probe still failing"); + } + let (_, wake) = &*FRONTEND_READY_SIGNAL; + wake.notify_all(); } -fn should_attempt_enterprise_recovery( - active: bool, - enterprise_compat: bool, - enterprise_compat_disabled: bool, - run_event_count: usize, - main_exists: bool, - main_visible: bool, -) -> bool { - if !active || enterprise_compat || enterprise_compat_disabled { +/// Cheap probe used by `lib.rs` around `show_main_window` calls. +pub(crate) fn probe_main_window_visible(app: &tauri::AppHandle) -> bool { + app.get_webview_window("main").and_then(|window| window.is_visible().ok()).unwrap_or(false) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StartupWaitOutcome { + Ready, + TimedOut, + FastFail, +} + +fn wait_for_ready_or_failure(timeout: Duration, fast_fail_grace: Duration) -> StartupWaitOutcome { + let (signal, wake) = &*FRONTEND_READY_SIGNAL; + let mut ready = signal.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let deadline = Instant::now() + timeout; + let mut fast_fail_deadline: Option = None; + loop { + if *ready { + return StartupWaitOutcome::Ready; + } + let now = Instant::now(); + if now >= deadline { + return StartupWaitOutcome::TimedOut; + } + if fast_fail_deadline.is_none() && WINDOW_PROBE_FAILED.load(Ordering::Acquire) { + fast_fail_deadline = Some((now + fast_fail_grace).min(deadline)); + } + if let Some(fast_fail_deadline) = fast_fail_deadline { + if now >= fast_fail_deadline { + return StartupWaitOutcome::FastFail; + } + } + let wake_at = fast_fail_deadline.unwrap_or(deadline); + let (next_ready, wait_result) = wake + .wait_timeout(ready, wake_at.saturating_duration_since(now)) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + ready = next_ready; + if *ready { + return StartupWaitOutcome::Ready; + } + if !wait_result.timed_out() { + // Spurious/other notification: re-check state immediately. + continue; + } + } +} + +fn should_escalate_startup(compat_disabled: bool, outcome: StartupWaitOutcome) -> bool { + if compat_disabled { return false; } + matches!(outcome, StartupWaitOutcome::TimedOut | StartupWaitOutcome::FastFail) +} - // A window object can exist even when WebView2 or a security product has - // prevented it from being presented. The event loop may already be - // running in that state, so run_event_count cannot be used as a veto. - (!main_exists && run_event_count == 0) || (main_exists && !main_visible) +fn main_window_snapshot(app: &tauri::AppHandle) -> (bool, bool) { + let window = app.get_webview_window("main"); + let exists = window.is_some(); + let visible = window.as_ref().and_then(|window| window.is_visible().ok()).unwrap_or(false); + (exists, visible) +} + +fn watchdog_delay_override() -> Option { + std::env::var(WATCHDOG_SECS_ENV).ok().and_then(|value| value.parse::().ok()).map(Duration::from_secs) } pub(crate) fn start_watchdog(app: &tauri::AppHandle) { @@ -411,75 +1014,189 @@ pub(crate) fn start_watchdog(app: &tauri::AppHandle) { } let app = app.clone(); std::thread::spawn(move || { - let (signal, wake) = &*FRONTEND_READY_SIGNAL; - let ready = signal.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - let (ready, timeout) = wake - .wait_timeout_while(ready, STARTUP_WATCHDOG_DELAY, |ready| !*ready) - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if *ready || !timeout.timed_out() { - return; - } - drop(ready); - - let (active, enterprise_compat, enterprise_compat_disabled, run_event_count) = probe_snapshot(); - if !active { - return; - } - let main_window = app.get_webview_window("main"); - let main_exists = main_window.is_some(); - let main_visible = main_window.as_ref().is_some_and(|window| window.is_visible().unwrap_or(false)); + let profile = current_profile(); + let base_timeout = profile.map(|profile| profile.watchdog).unwrap_or(STARTUP_WATCHDOG_DELAY); + let timeout = watchdog_delay_override().unwrap_or(base_timeout); + let fast_fail_grace = env_duration_ms(FAST_FAIL_GRACE_MS_ENV, FAST_FAIL_GRACE); record(format!( - "startup watchdog after {}s run_event_count={run_event_count} main_exists={main_exists} main_visible={main_visible}", - STARTUP_WATCHDOG_DELAY.as_secs() + "startup watchdog armed timeout_secs={} fast_fail_grace_ms={} profile={} marker_mode={}", + timeout.as_secs(), + fast_fail_grace.as_millis(), + current_profile_index(), + MARKER_MODE.load(Ordering::Acquire) )); - if should_attempt_enterprise_recovery( - active, - enterprise_compat, - enterprise_compat_disabled, - run_event_count, - main_exists, - main_visible, - ) { - persist_buffer(); - record("startup main window was not visible; restarting once with enterprise compatibility mode"); - let restart_result = std::env::current_exe().and_then(|executable| { - let mut command = std::process::Command::new(executable); - command.args(std::env::args_os().skip(1)); - configure_recovery_child(&mut command, std::process::id()); - command.spawn() - }); - match restart_result { - Ok(child) => { - record(format!("enterprise compatibility process spawned pid={}", child.id())); - std::process::exit(0); - } - Err(error) => { - record(format!("failed to spawn enterprise compatibility process: {error}")); - show_recovery_failure_message(); - return; - } - } + let outcome = wait_for_ready_or_failure(timeout, fast_fail_grace); + if outcome == StartupWaitOutcome::Ready { + record("startup watchdog: main window became visible before the deadline"); + return; } - if enterprise_compat { - if main_visible { - return; - } + let run_event_count = RUN_EVENT_COUNT.load(Ordering::Acquire); + let (main_exists, main_visible) = main_window_snapshot(&app); + if main_visible { + record("startup watchdog: main window is visible but frontend never reported ready; not escalating"); + return; + } + record(format!( + "startup watchdog after {}s outcome={outcome:?} run_event_count={run_event_count} main_exists={main_exists} main_visible={main_visible} profile={} history={}", + timeout.as_secs(), + current_profile_index(), + format_history(&std::env::var(COMPAT_HISTORY_ENV).unwrap_or_default()) + )); + let compat_disabled = COMPAT_DISABLED.load(Ordering::Acquire); + if !should_escalate_startup(compat_disabled, outcome) { persist_buffer(); - record("enterprise compatibility startup failed; automatic recovery stopped"); + if compat_disabled { + append_persisted_record("startup compatibility disabled by environment; not retrying"); + } show_recovery_failure_message(); std::process::exit(1); } + escalate_to_next_profile(outcome); }); } -fn write_compatibility_marker() -> Result<(), String> { - let path = compatibility_marker_path().ok_or_else(|| "APPDATA is unavailable".to_string())?; - if !ensure_parent_dir(&path) { - return Err("failed to create compatibility marker directory".to_string()); +fn escalate_to_next_profile(outcome: StartupWaitOutcome) { + let current = current_profile_index(); + let history = std::env::var(COMPAT_HISTORY_ENV).unwrap_or_default(); + let history = appended_history(&history, current); + let stage = match outcome { + StartupWaitOutcome::TimedOut => "watchdog_timeout", + StartupWaitOutcome::FastFail => "window_probe_failed", + StartupWaitOutcome::Ready => "ready", + }; + record(format!("compat attempt failed profile={current} stage={stage} attempted={}", format_history(&history))); + persist_buffer(); + let mut next_index = current.saturating_add(1).max(1); + while next_index <= max_compat_profile_index() { + let Some(profile) = compat_profile(next_index) else { break }; + if profile.elevated { + let already_attempted = env_flag(COMPAT_ELEVATED_ENV); + let already_elevated = crate::windows_appcompat::is_process_elevated(); + if already_attempted || already_elevated { + record(format!( + "compat profile {} skipped: already_elevated={already_elevated} elevated_attempted={already_attempted}", + profile.index + )); + next_index += 1; + continue; + } + } + match launch_compat_profile(profile, &history) { + Ok(launch) => { + record(format!( + "compat profile spawned profile={} id={} launch={} child_pid={:?}", + profile.index, profile.id, launch.kind, launch.pid + )); + std::process::exit(0); + } + Err(error) => { + record(format!("failed to launch compat profile {}: {error}", profile.index)); + next_index += 1; + } + } } - std::fs::write(&path, compatibility_marker_contents(env!("CARGO_PKG_VERSION"))) - .map_err(|error| error.to_string())?; - Ok(()) + record(format!("all startup compatibility profiles failed support_code={SUPPORT_CODE_COMPAT_EXHAUSTED}")); + append_persisted_record(format!( + "all startup compatibility profiles failed support_code={SUPPORT_CODE_COMPAT_EXHAUSTED}" + )); + match crate::windows_appcompat::remove_win8_compat_layer() { + Ok(summary) => append_persisted_record(format!("compat cleanup: {summary}")), + Err(error) => append_persisted_record(format!("compat cleanup failed: {error}")), + } + show_compatibility_failure_message(&history); + std::process::exit(1); +} + +struct CompatLaunch { + kind: &'static str, + pid: Option, +} + +/// Starts the next executable in the chain. Normal launches pass the profile +/// through environment variables (the child applies it before Tauri builds). +/// The elevated profile additionally sets the variables in this process because +/// ShellExecuteW inherits the caller's environment. +fn launch_compat_profile(profile: &CompatProfile, history: &str) -> Result { + let child_history = appended_history(history, profile.index); + let chain_env = [ + (COMPAT_PROFILE_ENV.to_string(), profile.index.to_string()), + (RECOVERY_ATTEMPT_ENV.to_string(), "1".to_string()), + (RECOVERY_PARENT_PID_ENV.to_string(), std::process::id().to_string()), + (COMPAT_HISTORY_ENV.to_string(), child_history.clone()), + ]; + if profile.elevated { + return launch_elevated(profile, &chain_env); + } + let executable = if profile.temp_copy { + copy_executable_to_temp()? + } else { + std::env::current_exe().map_err(|error| error.to_string())? + }; + let mut command = std::process::Command::new(&executable); + command.args(std::env::args_os().skip(1)); + for (key, value) in &chain_env { + command.env(key, value); + } + let child = command.spawn().map_err(|error| error.to_string())?; + Ok(CompatLaunch { kind: if profile.temp_copy { "temp_copy" } else { "spawn" }, pid: Some(child.id()) }) +} + +fn copy_executable_to_temp() -> Result { + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let temp_dir = std::env::temp_dir().join("dbx-startup-compat"); + std::fs::create_dir_all(&temp_dir).map_err(|error| format!("create temp dir failed: {error}"))?; + let target = temp_dir.join(executable.file_name().unwrap_or_else(|| std::ffi::OsStr::new("DBX.exe"))); + std::fs::copy(&executable, &target).map_err(|error| format!("copy executable failed: {error}"))?; + Ok(target) +} + +#[cfg(target_os = "windows")] +fn launch_elevated(profile: &CompatProfile, chain_env: &[(String, String)]) -> Result { + use windows_sys::Win32::UI::Shell::ShellExecuteW; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + for (key, value) in chain_env { + std::env::set_var(key, value); + } + std::env::set_var(COMPAT_ELEVATED_ENV, "1"); + let arguments = std::env::args_os() + .skip(1) + .map(|argument| { + let argument = argument.to_string_lossy().to_string(); + if argument.contains(' ') { + format!("\"{argument}\"") + } else { + argument + } + }) + .collect::>() + .join(" "); + let verb: Vec = "runas\0".encode_utf16().collect(); + let file: Vec = executable.to_string_lossy().encode_utf16().chain(std::iter::once(0)).collect(); + let parameters: Vec = arguments.encode_utf16().chain(std::iter::once(0)).collect(); + let result = unsafe { + ShellExecuteW( + std::ptr::null_mut(), + verb.as_ptr(), + file.as_ptr(), + parameters.as_ptr(), + std::ptr::null(), + SW_SHOWNORMAL, + ) + }; + let result_code = result as isize; + if result_code > 32 { + record(format!("compat profile {} relaunched with elevated privileges (UAC accepted)", profile.index)); + Ok(CompatLaunch { kind: "elevated", pid: None }) + } else { + Err(format!("ShellExecuteW(runas) failed code={result_code}")) + } +} + +#[cfg(not(target_os = "windows"))] +fn launch_elevated(profile: &CompatProfile, _chain_env: &[(String, String)]) -> Result { + Err(format!("elevated relaunch unsupported on this platform (profile {})", profile.index)) } fn deactivate_probe() { @@ -499,106 +1216,224 @@ pub(crate) fn mark_frontend_ready(main_window_visible: bool) { return; } - let recovery_attempt = RECOVERY_ATTEMPT.load(Ordering::Acquire); + let profile = current_profile(); + let profile_index = current_profile_index(); let keep_requested = env_flag(KEEP_STARTUP_LOG_ENV); + record(format!( + "frontend ready with visible main window profile={profile_index} marker_mode={} recovery_attempt={} history={}", + MARKER_MODE.load(Ordering::Acquire), + profile_index > 0, + format_history(&std::env::var(COMPAT_HISTORY_ENV).unwrap_or_default()) + )); + persist_buffer(); + deactivate_probe(); - if recovery_attempt { - record("enterprise compatibility recovery reached frontend ready"); - persist_buffer(); - deactivate_probe(); - let keep_compatibility = confirm_keep_compatibility_mode(); - let result = if keep_compatibility { - write_compatibility_marker().map(|()| "enterprise compatibility marker saved".to_string()) - } else { - Ok("enterprise compatibility marker declined by user".to_string()) - }; - append_persisted_record( - result.unwrap_or_else(|error| format!("enterprise compatibility marker failed: {error}")), - ); - std::env::remove_var(RECOVERY_ATTEMPT_ENV); + if let Some(profile) = profile { + // No success popup: the app is already usable and a dialog would only + // interrupt the user. Remember the working method silently so the next + // launch is fast; if it fails again the remaining profiles are still + // tried automatically. + let profile = *profile; + let result = write_compatibility_marker(&profile) + .map(|()| "startup compatibility marker saved (remembered automatically)".to_string()) + .unwrap_or_else(|error| format!("startup compatibility marker failed: {error}")); + append_persisted_record(format!( + "compat profile succeeded profile={} id={}; {result}", + profile.index, profile.id + )); } else if keep_requested { record("frontend ready; startup log retained by DBX_KEEP_STARTUP_LOG=1"); - persist_buffer(); - deactivate_probe(); } else if let Some(path) = startup_log_path() { let _ = std::fs::remove_file(path); - deactivate_probe(); - } else { - deactivate_probe(); } } #[cfg(target_os = "windows")] -fn windows_ok_message(title: &str, body: &str) { - use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONWARNING, MB_OK, MB_SETFOREGROUND}; +fn windows_message_box(title: &str, body: &str, flags: u32) -> i32 { + use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_SETFOREGROUND}; - let title = title.encode_utf16().chain(std::iter::once(0)).collect::>(); - let body = body.encode_utf16().chain(std::iter::once(0)).collect::>(); - unsafe { - MessageBoxW(std::ptr::null_mut(), body.as_ptr(), title.as_ptr(), MB_OK | MB_ICONWARNING | MB_SETFOREGROUND); - } + let title: Vec = title.encode_utf16().chain(std::iter::once(0)).collect(); + let body: Vec = body.encode_utf16().chain(std::iter::once(0)).collect(); + unsafe { MessageBoxW(std::ptr::null_mut(), body.as_ptr(), title.as_ptr(), flags | MB_SETFOREGROUND) } } -#[cfg(target_os = "windows")] -fn confirm_keep_compatibility_mode() -> bool { - use windows_sys::Win32::UI::WindowsAndMessaging::{ - MessageBoxW, IDYES, MB_DEFBUTTON2, MB_ICONWARNING, MB_SETFOREGROUND, MB_YESNO, - }; +/// Stable support codes (startup.log only — the dialog copy already explains the +/// problem, so users do not need to see them). +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +const SUPPORT_CODE_MISSING_RUNTIME: &str = "DBX-STARTUP-001"; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +const SUPPORT_CODE_DAMAGED_RUNTIME: &str = "DBX-STARTUP-002"; +const SUPPORT_CODE_COMPAT_EXHAUSTED: &str = "DBX-STARTUP-003"; - let log_path = - startup_log_path().map(|path| path.display().to_string()).unwrap_or_else(|| "startup.log".to_string()); - let locale = sys_locale::get_locale().unwrap_or_default().to_ascii_lowercase(); - let body = if locale.starts_with("zh") { - format!( - "DBX 已通过企业环境兼容模式恢复主界面。\n\n该模式会为 WebView2 使用独立数据目录,并关闭沙箱和 GPU 加速,仅建议在标准模式无法显示窗口时保留。\n\n是否让当前 DBX 版本后续启动直接使用兼容模式?\n选择“否”后,下次启动会重新尝试标准模式。\n\n本次恢复日志:{log_path}" - ) - } else { - format!( - "DBX restored the main window using enterprise environment compatibility mode.\n\nThis mode uses an isolated WebView2 data directory and disables the sandbox and GPU acceleration. Keep it only when the standard mode cannot display the window.\n\nUse compatibility mode directly for future launches of this DBX version?\nChoose No to retry standard mode on the next launch.\n\nRecovery log: {log_path}" - ) - }; - let title = "DBX".encode_utf16().chain(std::iter::once(0)).collect::>(); - let body = body.encode_utf16().chain(std::iter::once(0)).collect::>(); +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn startup_log_display_path() -> String { + startup_log_path().map(|path| path.display().to_string()).unwrap_or_else(|| "startup.log".to_string()) +} + +/// Opens a URL in the default browser. Only fixed/derived URLs are passed here +/// (the offline-package download link for this build), never user input. +#[cfg(target_os = "windows")] +fn open_browser_url(url: &str) { + use windows_sys::Win32::UI::Shell::ShellExecuteW; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + + let verb: Vec = "open\0".encode_utf16().collect(); + let url: Vec = url.encode_utf16().chain(std::iter::once(0)).collect(); + // SAFETY: both strings are NUL-terminated UTF-16 and outlive the call; the + // caller passes a fixed URL constant/derived release URL. unsafe { - MessageBoxW( + ShellExecuteW( std::ptr::null_mut(), - body.as_ptr(), - title.as_ptr(), - MB_YESNO | MB_ICONWARNING | MB_DEFBUTTON2 | MB_SETFOREGROUND, - ) == IDYES + verb.as_ptr(), + url.as_ptr(), + std::ptr::null(), + std::ptr::null(), + SW_SHOWNORMAL, + ); } } #[cfg(not(target_os = "windows"))] -fn confirm_keep_compatibility_mode() -> bool { - false +#[allow(dead_code)] +fn open_browser_url(_url: &str) {} + +/// Opens Explorer with the startup log selected (or its folder when the log does +/// not exist), so "send us the log" is one click away. +#[cfg(target_os = "windows")] +fn open_log_folder() { + use windows_sys::Win32::UI::Shell::ShellExecuteW; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + + let log_path = startup_log_path(); + let parameters = match &log_path { + Some(log) if log.is_file() => format!("/select,\"{}\"", log.display()), + _ => { + let folder = log_path + .as_ref() + .and_then(|log| log.parent().map(|parent| parent.display().to_string())) + .unwrap_or_else(|| "%APPDATA%\\com.dbx.app".to_string()); + format!("\"{folder}\"") + } + }; + let verb: Vec = "open\0".encode_utf16().collect(); + let file: Vec = "explorer.exe\0".encode_utf16().collect(); + let parameters: Vec = parameters.encode_utf16().chain(std::iter::once(0)).collect(); + // SAFETY: all strings are NUL-terminated UTF-16 and outlive the call; the + // command is a fixed explorer.exe invocation (no untrusted input). + unsafe { + ShellExecuteW( + std::ptr::null_mut(), + verb.as_ptr(), + file.as_ptr(), + parameters.as_ptr(), + std::ptr::null(), + SW_SHOWNORMAL, + ); + } } +#[cfg(not(target_os = "windows"))] +#[allow(dead_code)] +fn open_log_folder() {} + +/// Failure dialog for the WebView2-runtime cases (missing / damaged). The only +/// user action is downloading DBX's Windows 10 offline package, which embeds the +/// WebView2 installer; no Microsoft page, no extra report file, and no security +/// product names. +#[cfg(target_os = "windows")] +fn show_webview2_runtime_dialog(damaged: bool) { + use windows_sys::Win32::UI::WindowsAndMessaging::{IDYES, MB_DEFBUTTON1, MB_ICONWARNING, MB_YESNO}; + + let messages = crate::startup_messages::current(); + let log_path = startup_log_display_path(); + let offline_url = crate::startup_messages::offline_installer_url(); + let template = if damaged { messages.damaged_runtime } else { messages.missing_runtime }; + let body = messages.render(template, &log_path, &offline_url); + let open_download = windows_message_box(messages.title, &body, MB_YESNO | MB_ICONWARNING | MB_DEFBUTTON1) == IDYES; + if open_download { + open_browser_url(&offline_url); + } +} + +#[cfg(not(target_os = "windows"))] +#[allow(dead_code)] +fn show_webview2_runtime_dialog(_damaged: bool) {} + +/// Failure dialog when every startup-compatibility profile failed (real AV +/// blocking or another window-creation failure). No product names, no attempt +/// list (both are in startup.log); one single log file and one action. +#[cfg(target_os = "windows")] +fn show_compatibility_failure_message(_attempted_history: &str) { + use windows_sys::Win32::UI::WindowsAndMessaging::{IDYES, MB_DEFBUTTON1, MB_ICONWARNING, MB_YESNO}; + + let messages = crate::startup_messages::current(); + let log_path = startup_log_display_path(); + let offline_url = crate::startup_messages::offline_installer_url(); + let body = messages.render(messages.compatibility_failed, &log_path, &offline_url); + let open_logs = windows_message_box(messages.title, &body, MB_YESNO | MB_ICONWARNING | MB_DEFBUTTON1) == IDYES; + if open_logs { + open_log_folder(); + } +} + +#[cfg(not(target_os = "windows"))] +fn show_compatibility_failure_message(_attempted_history: &str) {} + +/// Failure dialog for the legacy disabled-compatibility path (`DBX_DISABLE_*`). #[cfg(target_os = "windows")] fn show_recovery_failure_message() { - let log_path = - startup_log_path().map(|path| path.display().to_string()).unwrap_or_else(|| "startup.log".to_string()); - let locale = sys_locale::get_locale().unwrap_or_default().to_ascii_lowercase(); - let body = if locale.starts_with("zh") { - format!("DBX 已尝试企业环境兼容模式,但主窗口仍未显示。\n\n请将此日志发给维护者:{log_path}") - } else { - format!( - "DBX tried enterprise environment compatibility mode, but the main window was still not visible.\n\nPlease send this log to the maintainer: {log_path}" - ) - }; - windows_ok_message("DBX", &body); + use windows_sys::Win32::UI::WindowsAndMessaging::{IDYES, MB_DEFBUTTON1, MB_ICONWARNING, MB_YESNO}; + + let messages = crate::startup_messages::current(); + let log_path = startup_log_display_path(); + let offline_url = crate::startup_messages::offline_installer_url(); + let body = messages.render(messages.recovery_disabled, &log_path, &offline_url); + let open_logs = windows_message_box(messages.title, &body, MB_YESNO | MB_ICONWARNING | MB_DEFBUTTON1) == IDYES; + if open_logs { + open_log_folder(); + } } #[cfg(not(target_os = "windows"))] fn show_recovery_failure_message() {} +/// A WebView2 version is registered but its files are missing/corrupted. +/// +/// Tauri's own version check (`wry::webview_version()`) only reads the registry, +/// so it passes in this state; window creation then fails/hangs and the startup +/// watchdog used to retry with "enterprise compatibility mode" — which cannot fix +/// broken runtime files and made an installation problem look like AV blocking. +/// This state was observed on a real client machine; show repair guidance instead +/// of running the compatibility ladder. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) fn handle_damaged_webview2_runtime(runtime_version: Option<&str>) { + record(format!( + "webview2 runtime is registered but its files are missing or corrupted (version={}) support_code={SUPPORT_CODE_DAMAGED_RUNTIME}; skipping startup compatibility profiles", + runtime_version.unwrap_or("") + )); + persist_buffer(); + show_webview2_runtime_dialog(true); +} + +/// The machine has no usable WebView2 runtime. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) fn handle_missing_webview2_runtime() { + record(format!( + "webview2 runtime is not installed support_code={SUPPORT_CODE_MISSING_RUNTIME}; skipping startup compatibility profiles" + )); + persist_buffer(); + show_webview2_runtime_dialog(false); +} + #[cfg(test)] mod tests { use super::{ - compatibility_marker_contents, compatibility_marker_path_from_appdata, compatibility_profile_path_from_inputs, - compatibility_profile_ready_record, configure_recovery_child, resolve_compatibility_mode, - should_attempt_enterprise_recovery, startup_log_dir_from_inputs, webview2_compatibility_arguments, - RECOVERY_ATTEMPT_ENV, RECOVERY_PARENT_PID_ENV, + appended_history, compat_profile, compat_profile_user_data_path, compatibility_marker_contents, + compatibility_marker_path_from_appdata, compatibility_profile_path_from_inputs, + compatibility_profile_ready_record, expected_marker_mismatch, format_history, max_compat_profile_index, + parse_compatibility_marker, parse_history, parse_profile_index, prepare_browser_environment, + should_escalate_startup, startup_log_dir_from_inputs, webview2_compatibility_arguments, CompatProfile, + CompatUserData, StartupWaitOutcome, COMPAT_PROFILES, STARTUP_WATCHDOG_DELAY, }; use std::ffi::OsString; use std::path::PathBuf; @@ -647,36 +1482,23 @@ mod tests { "local_appdata", ) ); - } - - #[test] - fn recovery_only_triggers_for_the_observed_hard_startup_stall() { - assert!(should_attempt_enterprise_recovery(true, false, false, 0, false, false)); - assert!(!should_attempt_enterprise_recovery(false, false, false, 0, false, false)); - assert!(!should_attempt_enterprise_recovery(true, true, false, 0, false, false)); - assert!(!should_attempt_enterprise_recovery(true, false, false, 1, false, false)); - assert!(!should_attempt_enterprise_recovery(true, false, false, 0, true, true)); - } - - #[test] - fn recovery_triggers_when_existing_main_window_is_still_hidden() { - assert!(should_attempt_enterprise_recovery(true, false, false, 1, true, false)); - assert!(should_attempt_enterprise_recovery(true, false, false, 0, true, false)); - assert!(!should_attempt_enterprise_recovery(true, false, false, 1, true, true)); - } - - #[test] - fn disable_enterprise_compat_prevents_recovery_during_a_hard_stall() { - assert!(!should_attempt_enterprise_recovery(true, false, true, 0, false, false)); - assert!(!should_attempt_enterprise_recovery(true, false, true, 1, true, false)); - } - - #[test] - fn disable_enterprise_compat_overrides_recovery_and_marker_modes() { - assert_eq!(resolve_compatibility_mode(true, false, true), (false, false)); - assert_eq!(resolve_compatibility_mode(false, true, true), (false, false)); - assert_eq!(resolve_compatibility_mode(true, false, false), (true, true)); - assert_eq!(resolve_compatibility_mode(false, true, false), (false, true)); + let (profile_path, source, dedicated) = compat_profile_user_data_path( + compat_profile(1), + Some(OsString::from(r"C:\Users\test\AppData\Local")), + Some(OsString::from(r"C:\Users\test\AppData\Roaming")), + None, + ); + assert_eq!( + profile_path, + Some( + PathBuf::from(r"C:\Users\test\AppData\Local") + .join("com.dbx.app") + .join("webview2-enterprise-compat") + .join("profile-1") + ) + ); + assert_eq!(source, "local_appdata"); + assert!(dedicated); } #[test] @@ -687,31 +1509,133 @@ mod tests { ); assert!(profile.is_some()); assert_eq!(source, "appdata_fallback"); - let record = compatibility_profile_ready_record(source); - assert_eq!(record, "enterprise compatibility profile ready source=appdata_fallback"); + let record = compatibility_profile_ready_record(None, source); + assert_eq!(record, "compat profile user data ready source=appdata_fallback profile=standard"); assert!(!record.contains("private-user")); assert!(!record.contains(r"C:\Users")); } #[test] - fn recovery_child_receives_parent_handoff_without_disabling_single_instance() { - let mut command = std::process::Command::new("dbx-test"); - configure_recovery_child(&mut command, 4242); - let envs = command.get_envs().collect::>(); - assert!(envs - .iter() - .any(|(key, value)| { *key == RECOVERY_ATTEMPT_ENV && value.is_some_and(|value| value == "1") })); - assert!(envs - .iter() - .any(|(key, value)| { *key == RECOVERY_PARENT_PID_ENV && value.is_some_and(|value| value == "4242") })); + fn temp_profile_uses_temp_directory() { + let temp_profile = COMPAT_PROFILES.iter().find(|profile| profile.user_data == CompatUserData::Temp).unwrap(); + let (path, source, dedicated) = compat_profile_user_data_path( + Some(temp_profile), + Some(OsString::from(r"C:\Users\test\AppData\Local")), + Some(OsString::from(r"C:\Users\test\AppData\Roaming")), + Some(OsString::from(r"C:\Users\test\AppData\Local\Temp")), + ); + assert_eq!( + path, + Some( + PathBuf::from(r"C:\Users\test\AppData\Local\Temp") + .join("dbx-webview2-compat") + .join(format!("profile-{}", temp_profile.index)) + ) + ); + assert_eq!(source, "temp"); + assert!(dedicated); } #[test] - fn compatibility_marker_is_scoped_to_the_current_version() { - assert_eq!( - compatibility_marker_contents("0.5.72"), - "version=0.5.72\nmode=isolated-profile-no-sandbox-disable-gpu\n" + fn default_profile_does_not_touch_user_data_folder() { + let (path, source, dedicated) = compat_profile_user_data_path( + None, + Some(OsString::from(r"C:\Users\test\AppData\Local")), + Some(OsString::from(r"C:\Users\test\AppData\Roaming")), + Some(OsString::from(r"C:\Users\test\AppData\Local\Temp")), ); + assert_eq!(path, None); + assert_eq!(source, "default"); + assert!(!dedicated); + } + + #[test] + fn compat_profiles_are_ordered_and_well_formed() { + assert!(!COMPAT_PROFILES.is_empty()); + for (offset, profile) in COMPAT_PROFILES.iter().enumerate() { + assert_eq!(profile.index as usize, offset + 1); + assert!(!profile.id.is_empty()); + assert!(!profile.label_zh.is_empty()); + assert!(!profile.label_en.is_empty()); + assert!(!profile.browser_args.trim().is_empty()); + assert!(!profile.browser_args.starts_with(' ')); + assert!(!profile.browser_args.ends_with(' ')); + assert!(profile.browser_args.contains("--no-sandbox")); + assert!(profile.start_delay < STARTUP_WATCHDOG_DELAY); + assert!(profile.watchdog >= std::time::Duration::from_secs(20)); + } + assert_eq!(max_compat_profile_index() as usize, COMPAT_PROFILES.len()); + let mut ids = COMPAT_PROFILES.iter().map(|profile| profile.id).collect::>(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), COMPAT_PROFILES.len()); + assert_eq!(compat_profile(1).unwrap().user_data, CompatUserData::Isolated); + assert!(COMPAT_PROFILES.iter().any(|profile| profile.warmup)); + assert!(COMPAT_PROFILES.iter().any(|profile| profile.appcompat_win8)); + assert!(COMPAT_PROFILES.iter().any(|profile| profile.start_delay > std::time::Duration::ZERO)); + assert!(COMPAT_PROFILES.iter().any(|profile| profile.elevated)); + assert!(COMPAT_PROFILES.iter().any(|profile| profile.temp_copy)); + } + + #[test] + fn marker_round_trips_and_is_version_scoped() { + let version = env!("CARGO_PKG_VERSION"); + let other_version = "0.0.0-other-build"; + let profile = compat_profile(3).unwrap(); + let contents = compatibility_marker_contents(version, profile); + assert_eq!(parse_compatibility_marker(&contents, version), Some(3)); + assert_eq!(parse_compatibility_marker(&contents, other_version), None); + assert!(!expected_marker_mismatch(&contents, version, 3)); + assert!(expected_marker_mismatch(&contents, other_version, 3)); + assert!(expected_marker_mismatch(&contents, version, 2)); + } + + #[test] + fn legacy_marker_maps_to_the_first_profile() { + let version = env!("CARGO_PKG_VERSION"); + let contents = format!("version={version}\nmode=isolated-profile-no-sandbox-disable-gpu\n"); + assert_eq!(parse_compatibility_marker(&contents, version), Some(1)); + } + + #[test] + fn profile_index_parsing_rejects_standard_and_garbage() { + assert_eq!(parse_profile_index(Some("3")), Some(3)); + assert_eq!(parse_profile_index(Some(" 2 ")), Some(2)); + assert_eq!(parse_profile_index(Some("0")), None); + assert_eq!(parse_profile_index(Some("x")), None); + assert_eq!(parse_profile_index(None), None); + } + + #[test] + fn history_is_appended_without_duplicates() { + assert_eq!(appended_history("", 1), "1"); + assert_eq!(appended_history("1,2", 3), "1,2,3"); + assert_eq!(appended_history("1,2", 2), "1,2"); + assert_eq!(parse_history("1,garbage,2"), vec![1, 2]); + assert_eq!(format_history("1,2"), "1:isolated-no-sandbox, 2:renderer-code-integrity-off"); + assert_eq!(format_history(""), "none"); + } + + #[test] + fn standard_startup_does_not_escalate_when_compat_is_disabled() { + assert!(!should_escalate_startup(true, StartupWaitOutcome::TimedOut)); + assert!(!should_escalate_startup(true, StartupWaitOutcome::FastFail)); + assert!(!should_escalate_startup(false, StartupWaitOutcome::Ready)); + assert!(should_escalate_startup(false, StartupWaitOutcome::TimedOut)); + assert!(should_escalate_startup(false, StartupWaitOutcome::FastFail)); + } + + #[test] + fn recovery_child_receives_parent_handoff_without_disabling_single_instance() { + let mut command = std::process::Command::new("dbx-test"); + super::configure_recovery_child(&mut command, 4242); + let envs = command.get_envs().collect::>(); + assert!(envs + .iter() + .any(|(key, value)| { *key == super::RECOVERY_ATTEMPT_ENV && value.is_some_and(|value| value == "1") })); + assert!(envs.iter().any(|(key, value)| { + *key == super::RECOVERY_PARENT_PID_ENV && value.is_some_and(|value| value == "4242") + })); } #[test] @@ -732,4 +1656,20 @@ mod tests { "--no-sandbox --disable-gpu" ); } + + #[test] + fn prepare_browser_environment_is_idempotent_and_profile_scoped() { + std::env::set_var(super::COMPAT_BASE_ARGS_ENV, ""); + let profile: &CompatProfile = &COMPAT_PROFILES[0]; + let first = prepare_browser_environment(Some(profile)); + let second = prepare_browser_environment(Some(profile)); + assert!(first.iter().any(|line| line.contains("compat browser args prepared"))); + assert!(second.iter().any(|line| line.contains("compat browser args prepared"))); + let effective = std::env::var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS").unwrap(); + assert_eq!(effective.matches("--no-sandbox").count(), 1); + assert_eq!(effective.matches("--disable-gpu").count(), 1); + std::env::remove_var(super::COMPAT_BASE_ARGS_ENV); + std::env::remove_var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"); + std::env::remove_var("WEBVIEW2_USER_DATA_FOLDER"); + } } diff --git a/src-tauri/src/startup_recovery_noop.rs b/src-tauri/src/startup_recovery_noop.rs index 00618e669f..5b38de9c86 100644 --- a/src-tauri/src/startup_recovery_noop.rs +++ b/src-tauri/src/startup_recovery_noop.rs @@ -6,4 +6,18 @@ pub(crate) fn mark_frontend_ready(_main_window_visible: bool) {} pub(crate) fn record_run_event() {} +pub(crate) fn run_profile_pre_tauri_hooks() {} + +pub(crate) fn report_window_visibility_probe(_main_window_visible: bool) {} + +pub(crate) fn probe_main_window_visible(_app: &tauri::AppHandle) -> bool { + false +} + pub(crate) fn start_watchdog(_app: &tauri::AppHandle) {} + +#[allow(dead_code)] +pub(crate) fn handle_missing_webview2_runtime() {} + +#[allow(dead_code)] +pub(crate) fn handle_damaged_webview2_runtime(_runtime_version: Option<&str>) {} diff --git a/src-tauri/src/webview2_preflight.rs b/src-tauri/src/webview2_preflight.rs new file mode 100644 index 0000000000..e5ee2717a9 --- /dev/null +++ b/src-tauri/src/webview2_preflight.rs @@ -0,0 +1,1476 @@ +//! Windows WebView2 startup preflight and diagnostics. +//! +//! WHY THIS MODULE EXISTS +//! +//! On some Windows 10 machines (notably those running 360 安全卫士 / 奇安信天擎 / +//! Kaspersky), `CreateCoreWebView2EnvironmentWithOptions` stalls for ~35 s and the +//! Tauri main window never becomes visible. The leading theory is that the +//! security product intercepts (scans, sandboxes or blocks) the +//! `msedgewebview2.exe` child processes that WebView2 spawns on environment +//! creation, so the ready callback never arrives or arrives far too late. DBX +//! therefore needs two independent capabilities: +//! +//! 1. **Diagnostics** ([`collect_diagnostics`] / [`format_diagnostics`]): capture +//! the facts that distinguish the failure modes (installed WebView2 runtime and +//! how it is located, user-data-folder class, additional browser arguments, +//! live `msedgewebview2.exe` process count, installed security products, Mark +//! of the Web (Zone.Identifier), process integrity level and Windows build) and +//! emit them as privacy-safe log lines that a client can email to us. +//! +//! 2. **Environment probe** ([`probe_environment`]): re-run exactly the WebView2 +//! environment creation that wry performs, but on a throwaway STA thread with a +//! hard deadline. This turns "the window never appears" into a structured +//! result (success / HRESULT / elapsed ms / timeout) without blocking or +//! crashing the app. +//! +//! 3. **Child-process warm-up** ([`spawn_preflight_child`] / +//! [`run_child_if_requested`]): spawn a copy of the DBX executable in a +//! diagnostics-only mode *before* the real Tauri window exists. On affected +//! machines the first WebView2 start is the slow one; letting a disposable child +//! absorb that cost makes the AV/EDR product scan and trust `msedgewebview2.exe` +//! while the user is still looking at the splash, so the real window creation +//! afterwards can succeed quickly. This is a known workaround for 360-style +//! startup blockers. +//! +//! PRIVACY CONTRACT +//! +//! `startup.log` is emailed to the maintainer, so it must never contain a user +//! name or a full user-profile path. [`format_diagnostics`] therefore only emits +//! path *classes* ("local_appdata", "appdata", "temp", "custom"), counts, version +//! numbers and product display names. Free-form values that may embed a profile +//! path (additional browser arguments) are scrubbed through [`sanitize_for_log`]. +//! +//! PLATFORM SCOPE +//! +//! This module is declared only under `#[cfg(target_os = "windows")]`; the body +//! below is Windows-only on purpose. The pure helpers at the bottom (path +//! classification, product matching, integrity mapping and result-file parsing) +//! are kept free of Win32 calls so they can be unit-tested on Windows CI. +//! +//! This file is the whole surface of the module: it does not call back into the +//! rest of the crate, which keeps it usable from both `run()` (child mode) and the +//! startup-recovery ladder. + +use std::collections::BTreeSet; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use webview2_com::Microsoft::Web::WebView2::Win32::{ + CreateCoreWebView2EnvironmentWithOptions, GetAvailableCoreWebView2BrowserVersionString, ICoreWebView2Environment, + ICoreWebView2EnvironmentOptions, +}; +use webview2_com::{CoreWebView2EnvironmentOptions, CreateCoreWebView2EnvironmentCompletedHandler}; +use windows::core::{Error as WinError, HRESULT, HSTRING, PCWSTR, PWSTR}; +use windows_sys::Win32::Foundation::{CloseHandle, ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, INVALID_HANDLE_VALUE}; +use windows_sys::Win32::Security::{ + GetSidSubAuthority, GetSidSubAuthorityCount, GetTokenInformation, TokenIntegrityLevel, TOKEN_MANDATORY_LABEL, + TOKEN_QUERY, +}; +use windows_sys::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED}; +use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, +}; +use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegEnumKeyExW, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, + KEY_WOW64_64KEY, REG_DWORD, REG_EXPAND_SZ, REG_SZ, REG_VALUE_TYPE, +}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; +use windows_sys::Win32::UI::WindowsAndMessaging::{DispatchMessageW, PeekMessageW, TranslateMessage, MSG, PM_REMOVE}; + +// --------------------------------------------------------------------------- +// Environment contract shared with lib.rs / startup_recovery.rs +// --------------------------------------------------------------------------- + +/// Set to `1` in the spawned diagnostics child; makes `run()` perform the +/// WebView2 probe and exit before any Tauri/window code runs. +pub(crate) const PREFLIGHT_CHILD_ENV: &str = "DBX_WEBVIEW2_PREFLIGHT_CHILD"; + +/// Path of the ASCII `key=value` result file the child writes. +pub(crate) const PREFLIGHT_OUT_ENV: &str = "DBX_WEBVIEW2_PREFLIGHT_OUT"; + +/// Child-side probe timeout in milliseconds (default 60000). The parent passes its +/// own timeout down so the child never outlives the parent's wait window. +pub(crate) const PREFLIGHT_TIMEOUT_MS_ENV: &str = "DBX_WEBVIEW2_PREFLIGHT_TIMEOUT_MS"; + +/// WebView2 loader reads this to override the browser arguments passed to the +/// browser process; wry applies the same value, so the probe mirrors it. +const ADDITIONAL_ARGS_ENV: &str = "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"; +/// WebView2 user data folder override; also honored by WebView2 itself when wry +/// passes an empty folder string. +const USER_DATA_FOLDER_ENV: &str = "WEBVIEW2_USER_DATA_FOLDER"; +/// Fixed/frozen WebView2 runtime folder override (`evergreen` is used otherwise). +const BROWSER_EXE_FOLDER_ENV: &str = "WEBVIEW2_BROWSER_EXECUTABLE_FOLDER"; +/// Directory the startup ladder appends `startup.log` to. The preflight child +/// result is written next to it so the client can send both files together. +const STARTUP_LOG_DIR_ENV: &str = "DBX_STARTUP_LOG_DIR"; + +const DEFAULT_CHILD_TIMEOUT_MS: u64 = 60_000; +const CHILD_WAIT_GRACE: Duration = Duration::from_secs(5); +const CHILD_POLL_INTERVAL: Duration = Duration::from_millis(50); +const PROBE_PUMP_INTERVAL: Duration = Duration::from_millis(25); +const MAX_SECURITY_PRODUCTS: usize = 8; +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +// WebView2 runtime discovery. The EdgeUpdate client GUID is the stable identity of +// the Evergreen WebView2 Runtime; its `pv` value is the version we report. +const EDGE_UPDATE_CLIENT_KEY: &str = r"SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; +const EDGE_UPDATE_CLIENT_KEY_WOW: &str = + r"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; +const WINDOWS_CURRENT_VERSION_KEY: &str = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion"; +const UNINSTALL_KEY: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"; +const UNINSTALL_KEY_WOW: &str = r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"; + +/// Uninstall `DisplayName` fragments of products known to interfere with WebView2 +/// startup. Matching is a case-insensitive substring test on purpose: vendors +/// append edition suffixes ("Kaspersky Total Security", "360安全卫士 13.0"). +const SECURITY_PRODUCT_KEYWORDS: &[&str] = &[ + "360", + "奇安信", + "qianxin", + "天擎", + "kaspersky", + "卡巴斯基", + "火绒", + "huorong", + "腾讯电脑管家", + "qqpcmgr", + "金山", + "kingsoft", + "norton", + "symantec", + "mcafee", + "eset", + "trend micro", + "avast", + "bitdefender", + "windows defender", +]; + +// --------------------------------------------------------------------------- +// Diagnostics +// --------------------------------------------------------------------------- + +/// Snapshot of everything we want to know about the Windows/WebView2/AV +/// environment at startup. Built best-effort: every field independently degrades +/// to a neutral value (`"unknown"`, `None`, `0`, empty) instead of failing. +#[derive(Debug, Clone, Default)] +pub(crate) struct Diagnostics { + /// Runtime version, e.g. `"128.0.2739.42"` or a fixed-runtime version. + pub runtime_version: Option, + /// Where [`Diagnostics::runtime_version`] came from: `registry_hklm`, + /// `registry_hkcu`, `api` or `missing`. + pub runtime_version_source: &'static str, + /// `evergreen`, `fixed_runtime` or `missing`. + pub runtime_install_kind: &'static str, + /// Class of the configured WebView2 user data folder (never the path itself). + pub user_data_folder_class: &'static str, + /// `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` if set. + pub additional_browser_arguments: Option, + /// Whether `WEBVIEW2_BROWSER_EXECUTABLE_FOLDER` points at a fixed runtime. + pub browser_executable_folder_set: bool, + /// Number of live `msedgewebview2.exe` processes at the time of collection. + pub webview2_process_count: u32, + /// Display names of installed products known to interfere with WebView2. + pub security_products: Vec, + /// Whether the running executable carries a Mark of the Web. + pub exe_has_zone_identifier: bool, + /// `low`, `medium`, `high`, `system` or `unknown`. + pub integrity_level: &'static str, + /// e.g. `"19045.3803 (22H2)"`. + pub windows_build: Option, +} + +/// Health of the WebView2 runtime as seen by this process. +/// +/// Distinguishing these three states is what keeps DBX from blaming security +/// software for a broken runtime installation: +/// - [`Available`](WebView2RuntimeHealth::Available): Tauri can create a window. +/// - [`Missing`](WebView2RuntimeHealth::Missing): nothing registered; Tauri would +/// show its generic English dialog and fail. Show install guidance. +/// - [`RegisteredButDamaged`](WebView2RuntimeHealth::RegisteredButDamaged): a +/// version is registered (so Tauri's own version check passes!) but the runtime +/// files are gone/corrupted. This is the state that produced the misleading +/// "enterprise compatibility mode" dialog on one affected client: the window +/// creation failed for minutes while DBX blamed AV blocking. Tell the user to +/// repair WebView2. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum WebView2RuntimeHealth { + Available, + Missing, + RegisteredButDamaged, +} + +impl WebView2RuntimeHealth { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Available => "available", + Self::Missing => "missing", + Self::RegisteredButDamaged => "registered_but_damaged", + } + } +} + +/// Directory names that may host the Evergreen runtime: per-machine 32/64-bit and +/// per-user installations. +fn evergreen_application_roots() -> Vec<(std::path::PathBuf, &'static str)> { + let mut roots = Vec::new(); + if let Some(program_files_x86) = env_non_empty_os("ProgramFiles(x86)") { + roots.push(( + std::path::PathBuf::from(program_files_x86).join("Microsoft\\EdgeWebView\\Application"), + "program_files_x86", + )); + } + if let Some(program_files) = env_non_empty_os("ProgramFiles") { + roots.push(( + std::path::PathBuf::from(program_files).join("Microsoft\\EdgeWebView\\Application"), + "program_files", + )); + } + if let Some(local_appdata) = env_non_empty_os("LOCALAPPDATA") { + roots.push(( + std::path::PathBuf::from(local_appdata).join("Microsoft\\EdgeWebView\\Application"), + "local_appdata", + )); + } + roots +} + +/// Fixed runtime folder: `WEBVIEW2_BROWSER_EXECUTABLE_FOLDER`, or the folder +/// Tauri's `fixedRuntime` bundling puts next to the executable. +fn fixed_runtime_folder() -> Option { + let from_env = env_non_empty_os(BROWSER_EXE_FOLDER_ENV).map(std::path::PathBuf::from); + from_env.or_else(|| { + let executable = std::env::current_exe().ok()?; + let folder = executable.parent()?.join("webview2-fixed-runtime"); + folder.join("msedgewebview2.exe").is_file().then_some(folder) + }) +} + +fn has_fixed_runtime_files(folder: &std::path::Path) -> bool { + folder.join("msedgewebview2.exe").is_file() +} + +/// Classify the WebView2 runtime state before Tauri is built. +/// +/// `RegisteredButDamaged` requires both (a) a registry/loader-reported version and +/// (b) no `msedgewebview2.exe` under any known `EdgeWebView\Application` root — a +/// strong, false-positive-safe signal. A version directory mismatch alone counts +/// as available (WebView2 updater keeps the previous version around). +pub(crate) fn runtime_health() -> WebView2RuntimeHealth { + // Escape hatch: if the detection ever produces a false positive on a machine + // where DBX still works (unusual WebView2 deployment), this flag bypasses it. + if std::env::var_os("DBX_SKIP_WEBVIEW2_RUNTIME_CHECK").is_some_and(|value| value == "1") { + return WebView2RuntimeHealth::Available; + } + if let Some(folder) = fixed_runtime_folder() { + return if has_fixed_runtime_files(&folder) { + WebView2RuntimeHealth::Available + } else { + WebView2RuntimeHealth::RegisteredButDamaged + }; + } + let registered_version = + registry_webview2_version().map(|(version, _source)| version).or_else(|| api_webview2_version(None)); + let Some(registered_version) = registered_version else { + return WebView2RuntimeHealth::Missing; + }; + let mut runtime_files_found = false; + for (root, _source) in evergreen_application_roots() { + if root.join(®istered_version).join("msedgewebview2.exe").is_file() { + return WebView2RuntimeHealth::Available; + } + let Ok(entries) = std::fs::read_dir(&root) else { continue }; + for entry in entries.flatten() { + let candidate = entry.path().join("msedgewebview2.exe"); + if candidate.is_file() { + runtime_files_found = true; + } + } + } + if runtime_files_found { + WebView2RuntimeHealth::Available + } else { + WebView2RuntimeHealth::RegisteredButDamaged + } +} + +/// Collect the startup diagnostics snapshot. Best-effort: this never panics and +/// never propagates an OS error; a failed probe just leaves a field neutral. +pub(crate) fn collect_diagnostics() -> Diagnostics { + let browser_executable_folder = env_non_empty(BROWSER_EXE_FOLDER_ENV); + let (runtime_version, runtime_version_source, runtime_install_kind) = + detect_runtime_version(browser_executable_folder.as_deref()); + Diagnostics { + runtime_version, + runtime_version_source, + runtime_install_kind, + user_data_folder_class: classify_user_data_folder(), + additional_browser_arguments: env_non_empty(ADDITIONAL_ARGS_ENV), + browser_executable_folder_set: browser_executable_folder.is_some(), + webview2_process_count: webview2_process_count(), + security_products: detect_security_products(), + exe_has_zone_identifier: exe_has_zone_identifier(), + integrity_level: process_integrity_level(), + windows_build: windows_build_string(), + } +} + +/// Render the snapshot as one `key=value` line per fact, suitable for appending to +/// `startup.log`. Values are scrubbed of user-profile paths (see +/// [`sanitize_for_log`]); product names are the only free-form text. +/// +/// Exact line format (the prefix is stable so support scripts can grep it): +/// ```text +/// webview2_preflight runtime_version=128.0.2739.42 +/// webview2_preflight runtime_version_source=registry_hklm +/// webview2_preflight runtime_install_kind=evergreen +/// webview2_preflight user_data_folder_class=local_appdata +/// webview2_preflight additional_browser_arguments= +/// webview2_preflight browser_executable_folder_set=false +/// webview2_preflight webview2_process_count=0 +/// webview2_preflight security_products=360安全卫士; 奇安信天擎 +/// webview2_preflight exe_has_zone_identifier=false +/// webview2_preflight integrity_level=medium +/// webview2_preflight windows_build=19045.3803 (22H2) +/// ``` +pub(crate) fn format_diagnostics(d: &Diagnostics) -> Vec { + let additional_arguments = match d.additional_browser_arguments.as_deref() { + Some(arguments) if !arguments.trim().is_empty() => sanitize_for_log(arguments.trim()), + _ => "".to_string(), + }; + let security_products = if d.security_products.is_empty() { + "".to_string() + } else { + sanitize_for_log(&d.security_products.join("; ")) + }; + vec![ + format!("webview2_preflight runtime_version={}", d.runtime_version.as_deref().unwrap_or("")), + format!("webview2_preflight runtime_version_source={}", d.runtime_version_source), + format!("webview2_preflight runtime_install_kind={}", d.runtime_install_kind), + format!("webview2_preflight user_data_folder_class={}", d.user_data_folder_class), + format!("webview2_preflight additional_browser_arguments={additional_arguments}"), + format!("webview2_preflight browser_executable_folder_set={}", d.browser_executable_folder_set), + format!("webview2_preflight webview2_process_count={}", d.webview2_process_count), + format!("webview2_preflight security_products={security_products}"), + format!("webview2_preflight exe_has_zone_identifier={}", d.exe_has_zone_identifier), + format!("webview2_preflight integrity_level={}", d.integrity_level), + format!("webview2_preflight windows_build={}", d.windows_build.as_deref().unwrap_or("")), + ] +} + +/// Resolve the WebView2 runtime version. Registry is preferred because it is pure +/// read (no COM/loader startup that AV may also stall); the loader API is the +/// fallback and is the only way to see a fixed-runtime folder's version. +fn detect_runtime_version(fixed_runtime_folder: Option<&str>) -> (Option, &'static str, &'static str) { + if let Some(folder) = fixed_runtime_folder { + // A fixed runtime is located by folder, so only the loader can report it. + return match api_webview2_version(Some(folder)) { + Some(version) => (Some(version), "api", "fixed_runtime"), + None => (None, "missing", "fixed_runtime"), + }; + } + if let Some((version, source)) = registry_webview2_version() { + return (Some(version), source, "evergreen"); + } + match api_webview2_version(None) { + Some(version) => (Some(version), "api", "evergreen"), + None => (None, "missing", "missing"), + } +} + +/// Read the Evergreen runtime version from EdgeUpdate's per-machine (64-bit and +/// WOW6432Node) and per-user registry hives. +fn registry_webview2_version() -> Option<(String, &'static str)> { + let locations: [(HKEY, &str, u32, &'static str); 3] = [ + (HKEY_LOCAL_MACHINE, EDGE_UPDATE_CLIENT_KEY, KEY_READ | KEY_WOW64_64KEY, "registry_hklm"), + (HKEY_LOCAL_MACHINE, EDGE_UPDATE_CLIENT_KEY_WOW, KEY_READ, "registry_hklm"), + (HKEY_CURRENT_USER, EDGE_UPDATE_CLIENT_KEY, KEY_READ, "registry_hkcu"), + ]; + for (root, path, access, source) in locations { + if let Some(version) = reg_read_string_value(root, path, "pv", access) { + return Some((version, source)); + } + } + None +} + +/// Ask the WebView2 loader for the browser version. Passing `None` queries the +/// Evergreen runtime; a folder queries that fixed runtime. +fn api_webview2_version(browser_executable_folder: Option<&str>) -> Option { + let fixed_folder = browser_executable_folder.map(HSTRING::from); + let folder_ptr = fixed_folder.as_ref().map(|folder| PCWSTR(folder.as_ptr())).unwrap_or_else(PCWSTR::null); + let mut version = PWSTR::null(); + // SAFETY: the loader writes a CoTaskMem-allocated string into `version` on + // success; `take_pwstr` frees it. On failure `version` stays null. + match unsafe { GetAvailableCoreWebView2BrowserVersionString(folder_ptr, &mut version) } { + Ok(()) => { + let text = webview2_com::take_pwstr(version); + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + Err(_) => None, + } +} + +/// Classify `WEBVIEW2_USER_DATA_FOLDER` into a path class. "default" means the +/// variable is absent (WebView2/wry picks its own folder); "missing" means it is +/// present but blank, which is a broken profile worth flagging. +fn classify_user_data_folder() -> &'static str { + match std::env::var_os(USER_DATA_FOLDER_ENV) { + None => "default", + Some(value) => { + let value = value.to_string_lossy(); + if value.trim().is_empty() { + return "missing"; + } + let local_appdata = env_non_empty("LOCALAPPDATA"); + let appdata = env_non_empty("APPDATA"); + let temp = env_non_empty("TEMP"); + classify_path_with_roots(&value, local_appdata.as_deref(), appdata.as_deref(), temp.as_deref()) + } + } +} + +/// Pure path classification, parameterised so it can be unit-tested without a +/// Windows environment. "temp" wins over "local_appdata" because a temp folder is +/// normally inside `%LOCALAPPDATA%\Temp` and the temp location is the more +/// actionable fact when AV redirects writes. +fn classify_path_with_roots( + path: &str, + local_appdata: Option<&str>, + appdata: Option<&str>, + temp: Option<&str>, +) -> &'static str { + let normalized = path.replace('/', "\\").to_ascii_lowercase(); + let contains_root = |root: Option<&str>| { + root.map(str::trim) + .filter(|root| !root.is_empty()) + .map(|root| root.replace('/', "\\").to_ascii_lowercase()) + .is_some_and(|root| normalized.contains(&root)) + }; + if contains_root(temp) { + "temp" + } else if contains_root(local_appdata) { + "local_appdata" + } else if contains_root(appdata) { + "appdata" + } else if normalized.is_empty() { + "missing" + } else { + "custom" + } +} + +/// Count live `msedgewebview2.exe` processes. A non-zero count before DBX creates +/// its window means a previous/other WebView2 host is resident (which both changes +/// the blocking behavior and proves the runtime itself can start). +fn webview2_process_count() -> u32 { + // SAFETY: standard Toolhelp sequence; the snapshot handle is closed before + // returning and `entry` is fully initialized by the first Process32 call. + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snapshot == INVALID_HANDLE_VALUE { + return 0; + } + let mut entry = PROCESSENTRY32W::default(); + entry.dwSize = std::mem::size_of::() as u32; + let mut count = 0; + if Process32FirstW(snapshot, &mut entry) != 0 { + loop { + if exe_name_matches(&entry.szExeFile) { + count += 1; + } + if Process32NextW(snapshot, &mut entry) == 0 { + break; + } + } + } + CloseHandle(snapshot); + count + } +} + +/// Scan the three Uninstall registry views for products known to hook WebView2 +/// startup. Failures (permission, missing key, corrupt value) are ignored by +/// design: diagnostics must never break startup. +fn detect_security_products() -> Vec { + let mut found = BTreeSet::new(); + let locations: [(HKEY, &str, u32); 3] = [ + (HKEY_LOCAL_MACHINE, UNINSTALL_KEY, KEY_READ | KEY_WOW64_64KEY), + (HKEY_LOCAL_MACHINE, UNINSTALL_KEY_WOW, KEY_READ), + (HKEY_CURRENT_USER, UNINSTALL_KEY, KEY_READ), + ]; + for (root, path, access) in locations { + if found.len() >= MAX_SECURITY_PRODUCTS { + break; + } + if let Some(base) = reg_open(root, path, access) { + scan_uninstall_key(base, &mut found); + // SAFETY: `base` was opened by us and is not used afterwards. + unsafe { RegCloseKey(base) }; + } + } + found.into_iter().take(MAX_SECURITY_PRODUCTS).collect() +} + +/// Enumerate one `...\Uninstall` key and collect matching `DisplayName` values. +fn scan_uninstall_key(base: HKEY, found: &mut BTreeSet) { + let mut index = 0u32; + loop { + if found.len() >= MAX_SECURITY_PRODUCTS || index > 4096 { + return; + } + let mut name = [0u16; 256]; + let mut name_len = name.len() as u32; + // SAFETY: `name` has room for 256 UTF-16 code units (max key name is 255). + let status = unsafe { + RegEnumKeyExW( + base, + index, + name.as_mut_ptr(), + &mut name_len, + std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if status == ERROR_NO_MORE_ITEMS { + return; + } + if status != ERROR_SUCCESS { + return; + } + index = index.saturating_add(1); + let subkey = String::from_utf16_lossy(&name[..name_len as usize]); + let Some(key) = reg_open(base, &subkey, KEY_READ) else { continue }; + let display_name = reg_value_string(key, "DisplayName"); + // SAFETY: `key` was opened by us and is not used afterwards. + unsafe { RegCloseKey(key) }; + if let Some(display_name) = display_name { + if is_security_product(&display_name) { + found.insert(display_name); + } + } + } +} + +/// Whether a Mark of the Web (`Zone.Identifier` alternate data stream) is attached +/// to the running executable. A downloaded-and-extracted DBX carries MOTW, which +/// makes SmartScreen/AV treat the whole process tree (including the WebView2 +/// children) as untrusted — a common reason environment creation is stalled. +fn exe_has_zone_identifier() -> bool { + let Ok(exe) = std::env::current_exe() else { + return false; + }; + let stream_path = format!("{}:Zone.Identifier", exe.display()); + match std::fs::read(&stream_path) { + Ok(bytes) => bytes.windows(7).any(|window| window.eq_ignore_ascii_case(b"ZoneId=")), + Err(_) => false, + } +} + +/// Read the process integrity level from the token. Low/medium integrity programs +/// are often blocked from spawning the AppContainer-related WebView2 processes on +/// hardened machines. +fn process_integrity_level() -> &'static str { + // SAFETY: token and buffer lifetimes are confined to this function; every + // pointer is null-checked before dereferencing. + unsafe { + let mut token = std::ptr::null_mut(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 || token.is_null() { + return "unknown"; + } + let mut needed = 0u32; + let _ = GetTokenInformation(token, TokenIntegrityLevel, std::ptr::null_mut(), 0, &mut needed); + if needed == 0 { + CloseHandle(token); + return "unknown"; + } + // `usize` backing keeps TOKEN_MANDATORY_LABEL naturally aligned. + let mut buffer = vec![0usize; (needed as usize).div_ceil(std::mem::size_of::())]; + let read = GetTokenInformation(token, TokenIntegrityLevel, buffer.as_mut_ptr().cast(), needed, &mut needed); + CloseHandle(token); + if read == 0 { + return "unknown"; + } + let label = &*(buffer.as_ptr() as *const TOKEN_MANDATORY_LABEL); + let sid = label.Label.Sid; + if sid.is_null() { + return "unknown"; + } + let count = GetSidSubAuthorityCount(sid); + if count.is_null() { + return "unknown"; + } + let sub_authority_count = *count as u32; + if sub_authority_count == 0 { + return "unknown"; + } + let rid = GetSidSubAuthority(sid, sub_authority_count - 1); + if rid.is_null() { + return "unknown"; + } + integrity_level_from_rid(*rid) + } +} + +/// Map an integrity SID RID to its name. Values outside the standard four levels +/// (e.g. protected process 0x5000) are reported as unknown rather than guessed. +fn integrity_level_from_rid(rid: u32) -> &'static str { + match rid { + 0x1000 => "low", + 0x2000 => "medium", + 0x3000 => "high", + 0x4000 => "system", + _ => "unknown", + } +} + +/// Compose the Windows build string from `CurrentBuildNumber`/`CurrentBuild`, +/// `UBR` and `DisplayVersion` (e.g. `"19045.3803 (22H2)"`). `DisplayVersion` +/// identifies feature updates on Windows 10 builds that share a build number. +fn windows_build_string() -> Option { + let key = reg_open(HKEY_LOCAL_MACHINE, WINDOWS_CURRENT_VERSION_KEY, KEY_READ | KEY_WOW64_64KEY)?; + let build = reg_value_string(key, "CurrentBuildNumber").or_else(|| reg_value_string(key, "CurrentBuild")); + let display_version = reg_value_string(key, "DisplayVersion"); + let ubr = reg_value_dword(key, "UBR"); + // SAFETY: `key` was opened by us and is not used afterwards. + unsafe { RegCloseKey(key) }; + let mut build = build?; + if let Some(ubr) = ubr { + build.push('.'); + let _ = write!(build, "{ubr}"); + } + match display_version.filter(|version| !version.is_empty()) { + Some(display_version) => Some(format!("{build} ({display_version})")), + None => Some(build), + } +} + +// --------------------------------------------------------------------------- +// Environment probe (STA thread with hard deadline) +// --------------------------------------------------------------------------- + +/// Outcome of creating and releasing a WebView2 environment. +#[derive(Debug, Clone)] +pub(crate) struct EnvironmentProbe { + /// Environment creation completed successfully. + pub ok: bool, + /// `"0x80070005"`-style HRESULT when failed. + pub hresult: Option, + /// Windows system error description when available. + pub message: Option, + /// Wall-clock time from probe start to result/timeout. + pub elapsed_ms: u64, + /// True when the deadline expired and the probe thread was abandoned. + pub timed_out: bool, +} + +/// Create (then release) a WebView2 environment on a dedicated STA thread with +/// message pumping, bounded by `timeout`. +/// +/// This mirrors what wry does during window creation so the result is +/// representative, but it is isolated from the app: on deadline expiry the helper +/// thread is abandoned (and dies with the process) instead of blocking startup. +/// It never panics. +/// +/// `additional_args` / `user_data_folder` override the process environment; when +/// `None` the current `WEBVIEW2_*` values are used, matching wry's behavior. The +/// fixed-runtime folder is always taken from the environment, as wry does. +pub(crate) fn probe_environment( + additional_args: Option<&str>, + user_data_folder: Option<&Path>, + timeout: std::time::Duration, +) -> EnvironmentProbe { + let started = Instant::now(); + let additional_args = additional_args.map(str::to_string).or_else(|| env_non_empty(ADDITIONAL_ARGS_ENV)); + let user_data_folder = + user_data_folder.map(Path::to_path_buf).or_else(|| env_non_empty(USER_DATA_FOLDER_ENV).map(PathBuf::from)); + let browser_executable_folder = env_non_empty(BROWSER_EXE_FOLDER_ENV); + + let (tx, rx) = mpsc::channel::(); + let spawned = std::thread::Builder::new().name("dbx-webview2-preflight".to_string()).spawn(move || { + let outcome = probe_environment_on_sta_thread( + additional_args.as_deref(), + user_data_folder.as_deref(), + browser_executable_folder.as_deref(), + started, + timeout, + ); + let _ = tx.send(outcome); + }); + if spawned.is_err() { + return EnvironmentProbe { + ok: false, + hresult: None, + message: Some("failed to spawn the WebView2 preflight thread".to_string()), + elapsed_ms: elapsed_ms(started), + timed_out: false, + }; + } + + // `rx` is held by the caller thread; the timeout is the hard upper bound on + // how long startup waits for the probe, no matter what the AV product does. + match rx.recv_timeout(timeout) { + Ok(outcome) => EnvironmentProbe { + ok: outcome.ok, + hresult: outcome.hresult, + message: outcome.message, + elapsed_ms: outcome.elapsed_ms, + timed_out: outcome.timed_out, + }, + Err(mpsc::RecvTimeoutError::Timeout) => EnvironmentProbe { + ok: false, + hresult: None, + message: Some(format!("WebView2 environment creation did not complete within {} ms", timeout.as_millis())), + elapsed_ms: elapsed_ms(started), + timed_out: true, + }, + Err(mpsc::RecvTimeoutError::Disconnected) => EnvironmentProbe { + ok: false, + hresult: None, + message: Some("the WebView2 preflight thread ended without a result".to_string()), + elapsed_ms: elapsed_ms(started), + timed_out: false, + }, + } +} + +/// Internal representation sent from the probe thread back to the caller. It +/// deliberately contains no COM interface so it is `Send`. +struct ProbeOutcome { + ok: bool, + hresult: Option, + message: Option, + elapsed_ms: u64, + timed_out: bool, +} + +/// Data sent by the WebView2 completed handler. +struct ProbeCallbackOutcome { + ok: bool, + hresult: Option, + message: Option, +} + +/// Runs on the dedicated thread: initialize STA COM, attempt environment +/// creation, pump messages until the callback or the deadline. +fn probe_environment_on_sta_thread( + additional_args: Option<&str>, + user_data_folder: Option<&Path>, + browser_executable_folder: Option<&str>, + started: Instant, + timeout: Duration, +) -> ProbeOutcome { + // SAFETY: this thread is fresh, so it owns its COM apartment; it is + // uninitialized again before the thread returns. + let init_result = unsafe { CoInitializeEx(std::ptr::null(), COINIT_APARTMENTTHREADED as u32) }; + if init_result < 0 { + return ProbeOutcome { + ok: false, + hresult: Some(hresult_hex(init_result)), + message: Some(format!("CoInitializeEx failed: {}", WinError::from(HRESULT(init_result)).message())), + elapsed_ms: elapsed_ms(started), + timed_out: false, + }; + } + let outcome = + create_environment_and_wait(additional_args, user_data_folder, browser_executable_folder, started, timeout); + // SAFETY: balances the successful CoInitializeEx above; the thread issues no + // further COM calls afterwards. + unsafe { CoUninitialize() }; + outcome +} + +/// Build the options/handler (same shape as wry's `create_environment`) and pump +/// the thread's message queue until the completion callback arrives. +fn create_environment_and_wait( + additional_args: Option<&str>, + user_data_folder: Option<&Path>, + browser_executable_folder: Option<&str>, + started: Instant, + timeout: Duration, +) -> ProbeOutcome { + let (tx, rx) = mpsc::channel::(); + + let options = CoreWebView2EnvironmentOptions::default(); + if let Some(additional_args) = additional_args { + // SAFETY: `options` is a local object used only on this thread; the setter + // writes the same cell the COM putter would write. + unsafe { options.set_additional_browser_arguments(additional_args.to_string()) }; + } + let options: ICoreWebView2EnvironmentOptions = options.into(); + + let user_data_folder = + HSTRING::from(user_data_folder.map(|path| path.to_string_lossy().into_owned()).unwrap_or_default()); + let fixed_runtime_folder = browser_executable_folder.map(HSTRING::from); + let fixed_runtime_folder_ptr = + fixed_runtime_folder.as_ref().map(|folder| PCWSTR(folder.as_ptr())).unwrap_or_else(PCWSTR::null); + + let handler = CreateCoreWebView2EnvironmentCompletedHandler::create(Box::new( + move |error_code: windows::core::Result<()>, + environment: Option| + -> windows::core::Result<()> { + let outcome = match (error_code, environment) { + // Releasing the environment here is intentional: the probe only + // needs to prove creation works, and holding it would keep a + // browser process alive for the lifetime of the app. + (Ok(()), Some(_environment)) => ProbeCallbackOutcome { ok: true, hresult: None, message: None }, + (Ok(()), None) => ProbeCallbackOutcome { + ok: false, + hresult: None, + message: Some("WebView2 reported success without returning an environment".to_string()), + }, + (Err(error), _) => ProbeCallbackOutcome { + ok: false, + hresult: Some(hresult_hex(error.code().0)), + message: Some(error.message()), + }, + }; + let _ = tx.send(outcome); + Ok(()) + }, + )); + + // SAFETY: called on the STA thread initialized above; every argument lives + // until the call returns. The completion handler is AddRef'd by WebView2, so + // it stays valid while we pump messages below. + let call = unsafe { + CreateCoreWebView2EnvironmentWithOptions(fixed_runtime_folder_ptr, &user_data_folder, &options, &handler) + }; + if let Err(error) = call { + return ProbeOutcome { + ok: false, + hresult: Some(hresult_hex(error.code().0)), + message: Some(error.message()), + elapsed_ms: elapsed_ms(started), + timed_out: false, + }; + } + pump_until_result(&rx, started, timeout) +} + +/// Pump the STA thread's message queue until the WebView2 callback arrives or the +/// deadline expires. `PeekMessage` keeps the queue non-blocking so the deadline is +/// checked regularly; `recv_timeout` avoids a busy loop while no callback is +/// pending. +fn pump_until_result(rx: &mpsc::Receiver, started: Instant, timeout: Duration) -> ProbeOutcome { + let deadline = started + timeout; + let mut message = MSG::default(); + loop { + match rx.try_recv() { + Ok(callback) => { + return ProbeOutcome { + ok: callback.ok, + hresult: callback.hresult, + message: callback.message, + elapsed_ms: elapsed_ms(started), + timed_out: false, + }; + } + Err(mpsc::TryRecvError::Disconnected) => { + return ProbeOutcome { + ok: false, + hresult: None, + message: Some("the WebView2 completion callback channel closed unexpectedly".to_string()), + elapsed_ms: elapsed_ms(started), + timed_out: false, + }; + } + Err(mpsc::TryRecvError::Empty) => {} + } + if Instant::now() >= deadline { + return ProbeOutcome { + ok: false, + hresult: None, + message: Some(format!( + "WebView2 environment creation did not complete within {} ms", + timeout.as_millis() + )), + elapsed_ms: elapsed_ms(started), + timed_out: true, + }; + } + // SAFETY: MSG is stack-local and PeekMessage/DispatchMessage are used from + // the thread that owns the message queue. + unsafe { + while PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_REMOVE) != 0 { + let _ = TranslateMessage(&message); + DispatchMessageW(&message); + } + } + let _ = rx.recv_timeout(PROBE_PUMP_INTERVAL); + } +} + +// --------------------------------------------------------------------------- +// Child-process warm-up +// --------------------------------------------------------------------------- + +/// Result of the parent-side warm-up child process. +#[derive(Debug, Clone)] +pub(crate) struct PreflightChildOutcome { + /// The child process could be spawned at all. + pub spawned: bool, + /// The child reported that environment creation succeeded. + pub ok: bool, + /// Probe HRESULT reported by the child, when failed. + pub hresult: Option, + /// Child-reported probe duration (or parent wait duration when no result). + pub elapsed_ms: u64, + /// The parent killed the child after the wait window, or the child timed out. + pub timed_out: bool, + /// Child process exit code, when available. + pub exit_code: Option, +} + +/// Child-process warm-up mode. +/// +/// Called at the very top of `run()`: when the preflight child environment flag is +/// set, perform the probe with the inherited `WEBVIEW2_*` overrides, write an +/// ASCII `key=value` result file and exit before Tauri starts. Returns `false` +/// (and does nothing) for a normal DBX launch. +pub(crate) fn run_child_if_requested() -> bool { + if !env_flag(PREFLIGHT_CHILD_ENV) { + return false; + } + let timeout = child_timeout_from_env(); + let probe = probe_environment(None, None, timeout); + if let Some(path) = env_non_empty_os(PREFLIGHT_OUT_ENV).map(PathBuf::from) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&path, format_child_result(&probe)); + } + // The abandoned probe thread (if any) dies with this process. Exiting directly + // is important: the child must not initialize Tauri, windows or plugins. + std::process::exit(0); +} + +/// Parent-side warm-up: spawn the current executable in child mode so the +/// AV/EDR product scans and trusts `msedgewebview2.exe` before the real Tauri +/// window is created. +/// +/// The child inherits the current `WEBVIEW2_*` process environment (which is what +/// wry will use), writes its probe result next to `startup.log`, and is killed if +/// it outlives `timeout + 5 s`. This function never panics and always returns. +pub(crate) fn spawn_preflight_child(timeout: std::time::Duration) -> PreflightChildOutcome { + let started = Instant::now(); + let result_path = preflight_result_path(); + if let Some(parent) = result_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + // Remove a stale result file first: a reused PID from an earlier run must not + // be mistaken for this run's result if the child dies before writing. + let _ = std::fs::remove_file(&result_path); + + let exe = match std::env::current_exe() { + Ok(exe) => exe, + Err(_) => { + return PreflightChildOutcome { + spawned: false, + ok: false, + hresult: None, + elapsed_ms: elapsed_ms(started), + timed_out: false, + exit_code: None, + }; + } + }; + + let mut command = std::process::Command::new(exe); + command + .env(PREFLIGHT_CHILD_ENV, "1") + .env(PREFLIGHT_OUT_ENV, &result_path) + .env(PREFLIGHT_TIMEOUT_MS_ENV, timeout.as_millis().to_string()) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + // The child already inherits the environment, but copying these explicitly + // documents (and pins) the profile the ladder has applied in the parent. + for name in [ADDITIONAL_ARGS_ENV, USER_DATA_FOLDER_ENV, BROWSER_EXE_FOLDER_ENV] { + if let Some(value) = std::env::var_os(name) { + command.env(name, value); + } + } + // A GUI-subsystem exe must not flash a console window when spawned. + { + use std::os::windows::process::CommandExt; + command.creation_flags(CREATE_NO_WINDOW); + } + + let mut child = match command.spawn() { + Ok(child) => child, + Err(_) => { + return PreflightChildOutcome { + spawned: false, + ok: false, + hresult: None, + elapsed_ms: elapsed_ms(started), + timed_out: false, + exit_code: None, + }; + } + }; + + let wait_deadline = started + timeout + CHILD_WAIT_GRACE; + let mut timed_out = false; + let mut exit_code = None; + loop { + match child.try_wait() { + Ok(Some(status)) => { + exit_code = status.code(); + break; + } + Ok(None) => { + if Instant::now() >= wait_deadline { + timed_out = true; + let _ = child.kill(); + if let Ok(status) = child.wait() { + exit_code = status.code(); + } + break; + } + std::thread::sleep(CHILD_POLL_INTERVAL); + } + Err(_) => { + let _ = child.kill(); + break; + } + } + } + + let parsed = std::fs::read_to_string(&result_path).ok().and_then(|text| parse_child_result(&text)); + match parsed { + Some(parsed) => PreflightChildOutcome { + spawned: true, + ok: parsed.ok && !timed_out, + hresult: parsed.hresult, + elapsed_ms: parsed.elapsed_ms, + timed_out: timed_out || parsed.timed_out, + exit_code, + }, + None => PreflightChildOutcome { + spawned: true, + ok: false, + hresult: None, + elapsed_ms: elapsed_ms(started), + timed_out, + exit_code, + }, + } +} + +/// Result file path used by the child: next to `startup.log` so a client can send +/// both files together. +fn preflight_result_path() -> PathBuf { + startup_log_dir().join(format!("webview2-preflight-{}.log", std::process::id())) +} + +/// Startup log directory resolution, mirroring `startup_recovery.rs`: +/// explicit override, then `%APPDATA%\com.dbx.app`, then the temp directory. +fn startup_log_dir() -> PathBuf { + if let Some(dir) = env_non_empty_os(STARTUP_LOG_DIR_ENV) { + return PathBuf::from(dir); + } + if let Some(appdata) = env_non_empty_os("APPDATA") { + return PathBuf::from(appdata).join("com.dbx.app"); + } + std::env::temp_dir() +} + +/// Serialize a probe result as ASCII `key=value` lines. Non-ASCII characters in +/// the Windows error message are escaped (`\u{...}`) so the file stays pure ASCII +/// (the child cannot know the console/file encoding the maintainer uses). +fn format_child_result(probe: &EnvironmentProbe) -> String { + let mut output = String::new(); + let _ = writeln!(output, "ok={}", u8::from(probe.ok)); + let _ = writeln!(output, "hresult={}", probe.hresult.as_deref().unwrap_or("")); + let _ = writeln!(output, "elapsed_ms={}", probe.elapsed_ms); + let _ = writeln!(output, "timed_out={}", u8::from(probe.timed_out)); + let message = probe.message.as_deref().map(ascii_escape_line).unwrap_or_else(|| "".to_string()); + let _ = writeln!(output, "message={message}"); + output +} + +/// Parsed subset of the child result file that the parent needs. +struct ParsedChildResult { + ok: bool, + hresult: Option, + elapsed_ms: u64, + timed_out: bool, +} + +/// Parse the ASCII `key=value` child result. Returns `None` when the file is +/// incomplete (`ok`/`elapsed_ms` missing), e.g. because the child was killed +/// mid-write. +fn parse_child_result(text: &str) -> Option { + let mut ok = None; + let mut hresult = None; + let mut elapsed_ms = None; + let mut timed_out = false; + for line in text.lines() { + let Some((key, value)) = line.split_once('=') else { continue }; + let value = value.trim(); + match key.trim() { + "ok" => ok = Some(value == "1" || value.eq_ignore_ascii_case("true")), + "hresult" => hresult = (!value.is_empty() && value != "").then(|| value.to_string()), + "elapsed_ms" => elapsed_ms = value.parse::().ok(), + "timed_out" => timed_out = value == "1" || value.eq_ignore_ascii_case("true"), + _ => {} + } + } + Some(ParsedChildResult { ok: ok?, hresult, elapsed_ms: elapsed_ms?, timed_out }) +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +fn elapsed_ms(started: Instant) -> u64 { + started.elapsed().as_millis() as u64 +} + +fn hresult_hex(code: i32) -> String { + format!("0x{:08X}", code as u32) +} + +/// Escape everything outside printable ASCII (and CR/LF) so a result file stays +/// pure ASCII even on a Chinese-locale Windows where error messages are localized. +fn ascii_escape_line(text: &str) -> String { + let mut escaped = String::with_capacity(text.len()); + for character in text.chars() { + match character { + '\r' | '\n' => escaped.push_str("\\n"), + character if character.is_ascii() => escaped.push(character), + character => { + let _ = write!(escaped, "\\u{{{:X}}}", character as u32); + } + } + } + escaped +} + +/// Trimmed, non-empty Unicode environment variable. +fn env_non_empty(name: &str) -> Option { + std::env::var(name).ok().map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) +} + +/// Trimmed, non-empty OS-string environment variable (paths). +fn env_non_empty_os(name: &str) -> Option { + std::env::var_os(name).filter(|value| !value.is_empty()) +} + +/// Interpret the conventional boolean environment forms. +fn env_flag(name: &str) -> bool { + std::env::var(name) + .map(|value| { + let value = value.trim(); + value == "1" || value.eq_ignore_ascii_case("true") + }) + .unwrap_or(false) +} + +fn child_timeout_from_env() -> Duration { + let millis = env_non_empty(PREFLIGHT_TIMEOUT_MS_ENV) + .and_then(|value| value.parse::().ok()) + .filter(|millis| *millis > 0) + .unwrap_or(DEFAULT_CHILD_TIMEOUT_MS); + Duration::from_millis(millis) +} + +/// Scrub user-profile roots from free-form text before it reaches `startup.log`. +/// The class names match `user_data_folder_class`, so a path in +/// `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` cannot leak the user name. +fn sanitize_for_log(text: &str) -> String { + if text.is_empty() { + return String::new(); + } + let roots = [ + (env_non_empty("USERPROFILE"), ""), + (env_non_empty("LOCALAPPDATA"), "local_appdata"), + (env_non_empty("APPDATA"), "appdata"), + (env_non_empty("TEMP"), "temp"), + ]; + let mut sanitized = text.to_string(); + for (root, replacement) in roots { + if let Some(root) = root { + sanitized = replace_ci(&sanitized, &root, replacement); + } + } + sanitized +} + +/// Case-insensitive literal replacement that preserves the original byte layout of +/// `haystack` (ASCII-only case folding, so multi-byte names stay valid). +fn replace_ci(haystack: &str, needle: &str, replacement: &str) -> String { + let pattern = needle.to_ascii_lowercase(); + if pattern.is_empty() { + return haystack.to_string(); + } + let lowered = haystack.to_ascii_lowercase(); + let mut output = String::with_capacity(haystack.len()); + let mut copied_until = 0; + let mut search_from = 0; + while let Some(relative) = lowered[search_from..].find(&pattern) { + let start = search_from + relative; + let end = start + pattern.len(); + output.push_str(&haystack[copied_until..start]); + output.push_str(replacement); + copied_until = end; + search_from = end; + } + output.push_str(&haystack[copied_until..]); + output +} + +/// Whether an executable name (UTF-16 from Toolhelp) is `msedgewebview2.exe`. +fn exe_name_matches(raw: &[u16]) -> bool { + let end = raw.iter().position(|unit| *unit == 0).unwrap_or(raw.len()); + let name = String::from_utf16_lossy(&raw[..end]); + name.trim().eq_ignore_ascii_case("msedgewebview2.exe") +} + +/// Whether an Uninstall `DisplayName` matches a known WebView2-hostile product. +fn is_security_product(display_name: &str) -> bool { + let lowered = display_name.to_lowercase(); + SECURITY_PRODUCT_KEYWORDS.iter().any(|keyword| lowered.contains(keyword)) +} + +/// Append a NUL terminator for Win32 wide-string APIs. +fn wide(text: &str) -> Vec { + text.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// Open a registry key relative to `root`; returns `None` on any error. +fn reg_open(root: HKEY, path: &str, access: u32) -> Option { + let path = wide(path); + let mut key: HKEY = std::ptr::null_mut(); + // SAFETY: `path` is NUL-terminated and outlives the call; `key` is only + // returned when the API reports success. + let status = unsafe { RegOpenKeyExW(root, path.as_ptr(), 0, access, &mut key) }; + (status == ERROR_SUCCESS && !key.is_null()).then_some(key) +} + +/// Read a REG_SZ / REG_EXPAND_SZ value as a trimmed string. +fn reg_value_string(key: HKEY, name: &str) -> Option { + let name = wide(name); + // Registry string values are capped far below this; a too-small buffer simply + // fails with ERROR_MORE_DATA and the value is skipped. + let mut buffer = vec![0u16; 512]; + let mut size_bytes = (buffer.len() * std::mem::size_of::()) as u32; + let mut value_type: REG_VALUE_TYPE = 0; + // SAFETY: buffer/size describe the same allocation; the API only writes within it. + let status = unsafe { + RegQueryValueExW( + key, + name.as_ptr(), + std::ptr::null(), + &mut value_type, + buffer.as_mut_ptr().cast(), + &mut size_bytes, + ) + }; + if status != ERROR_SUCCESS { + return None; + } + if value_type != REG_SZ && value_type != REG_EXPAND_SZ { + return None; + } + let length = (size_bytes as usize / std::mem::size_of::()).min(buffer.len()); + let text = String::from_utf16_lossy(&buffer[..length]); + let trimmed = text.trim_end_matches('\0').trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) +} + +/// Read a REG_DWORD value. +fn reg_value_dword(key: HKEY, name: &str) -> Option { + let name = wide(name); + let mut value: u32 = 0; + let mut size_bytes = std::mem::size_of::() as u32; + let mut value_type: REG_VALUE_TYPE = 0; + // SAFETY: `value` is a stack u32 and `size_bytes` matches its size. + let status = unsafe { + RegQueryValueExW( + key, + name.as_ptr(), + std::ptr::null(), + &mut value_type, + (&mut value as *mut u32).cast(), + &mut size_bytes, + ) + }; + if status != ERROR_SUCCESS || value_type != REG_DWORD || size_bytes < std::mem::size_of::() as u32 { + return None; + } + Some(value) +} + +fn reg_read_string_value(root: HKEY, path: &str, value: &str, access: u32) -> Option { + let key = reg_open(root, path, access)?; + let read = reg_value_string(key, value); + // SAFETY: `key` was opened by us and is not used afterwards. + unsafe { RegCloseKey(key) }; + read +} + +// --------------------------------------------------------------------------- +// Tests: pure helpers only (Windows CI runs these; no WebView2 runtime needed). +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_user_data_folder_classes() { + let local = r"C:\Users\alice\AppData\Local"; + let roaming = r"C:\Users\alice\AppData\Roaming"; + let temp = r"C:\Users\alice\AppData\Local\Temp"; + assert_eq!( + classify_path_with_roots( + r"C:\Users\alice\AppData\Local\DBX\EBWebView", + Some(local), + Some(roaming), + Some(temp) + ), + "local_appdata" + ); + assert_eq!( + classify_path_with_roots(r"C:\Users\alice\AppData\Roaming\DBX", Some(local), Some(roaming), Some(temp)), + "appdata" + ); + // Temp wins over local_appdata because %TEMP% lives below %LOCALAPPDATA%. + assert_eq!( + classify_path_with_roots(r"C:\Users\alice\AppData\Local\Temp\dbx", Some(local), Some(roaming), Some(temp)), + "temp" + ); + assert_eq!(classify_path_with_roots(r"D:\Diagnostics\DBX", Some(local), Some(roaming), Some(temp)), "custom"); + // Windows paths are case-insensitive. + assert_eq!( + classify_path_with_roots( + r"c:\users\ALICE\appdata\local\dbx", + Some(r"C:\USERS\Alice\AppData\Local"), + Some(roaming), + Some(temp) + ), + "local_appdata" + ); + // Forward slashes are normalized. + assert_eq!( + classify_path_with_roots(r"C:/Users/alice/AppData/Local/x", Some(local), None, None), + "local_appdata" + ); + assert_eq!(classify_path_with_roots("", Some(local), None, None), "missing"); + } + + #[test] + fn matches_known_security_products() { + assert!(is_security_product("360安全卫士")); + assert!(is_security_product("奇安信天擎终端安全管理系统")); + assert!(is_security_product("Kaspersky Total Security")); + assert!(is_security_product("火绒安全软件")); + assert!(is_security_product("Windows Defender")); + assert!(!is_security_product("Notepad++")); + assert!(!is_security_product("Microsoft Visual C++ 2015 Redistributable")); + // Keyword matching is case-insensitive. + assert!(is_security_product("QQPCMgr")); + } + + #[test] + fn maps_integrity_level_rids() { + assert_eq!(integrity_level_from_rid(0x1000), "low"); + assert_eq!(integrity_level_from_rid(0x2000), "medium"); + assert_eq!(integrity_level_from_rid(0x3000), "high"); + assert_eq!(integrity_level_from_rid(0x4000), "system"); + assert_eq!(integrity_level_from_rid(0x5000), "unknown"); + assert_eq!(integrity_level_from_rid(0), "unknown"); + } + + #[test] + fn formats_hresult_hex() { + assert_eq!(hresult_hex(-2147024891), "0x80070005"); + assert_eq!(hresult_hex(0), "0x00000000"); + } + + #[test] + fn matches_webview2_process_name() { + let mut name = [0u16; 260]; + for (index, unit) in "msedgewebview2.exe".encode_utf16().enumerate() { + name[index] = unit; + } + assert!(exe_name_matches(&name)); + let mut upper = [0u16; 260]; + for (index, unit) in "MSEDGEWEBVIEW2.EXE".encode_utf16().enumerate() { + upper[index] = unit; + } + assert!(exe_name_matches(&upper)); + let mut other = [0u16; 260]; + for (index, unit) in "msedge.exe".encode_utf16().enumerate() { + other[index] = unit; + } + assert!(!exe_name_matches(&other)); + } + + #[test] + fn child_result_round_trips_success_and_failure() { + let failed = EnvironmentProbe { + ok: false, + hresult: Some("0x80070005".to_string()), + message: Some("Access is denied.".to_string()), + elapsed_ms: 1234, + timed_out: false, + }; + let parsed = parse_child_result(&format_child_result(&failed)).expect("result must parse"); + assert!(!parsed.ok); + assert_eq!(parsed.hresult.as_deref(), Some("0x80070005")); + assert_eq!(parsed.elapsed_ms, 1234); + assert!(!parsed.timed_out); + + let timed_out = EnvironmentProbe { + ok: false, + hresult: None, + message: Some("did not complete".to_string()), + elapsed_ms: 60001, + timed_out: true, + }; + let parsed = parse_child_result(&format_child_result(&timed_out)).expect("result must parse"); + assert!(!parsed.ok); + assert_eq!(parsed.hresult, None); + assert_eq!(parsed.elapsed_ms, 60_001); + assert!(parsed.timed_out); + + let succeeded = EnvironmentProbe { ok: true, hresult: None, message: None, elapsed_ms: 87, timed_out: false }; + let parsed = parse_child_result(&format_child_result(&succeeded)).expect("result must parse"); + assert!(parsed.ok); + assert_eq!(parsed.hresult, None); + } + + #[test] + fn child_result_file_is_ascii_and_escaped() { + let probe = EnvironmentProbe { + ok: false, + hresult: None, + message: Some("无法创建 WebView2 环境".to_string()), + elapsed_ms: 5, + timed_out: true, + }; + let text = format_child_result(&probe); + assert!(text.is_ascii(), "result file must stay ASCII: {text}"); + assert!(text.contains("\\u{")); + // An incomplete file (missing ok/elapsed_ms) must not parse. + assert!(parse_child_result("hresult=0x1\n").is_none()); + } + + #[test] + fn scrubs_profile_roots_case_insensitively() { + let text = r"--user-data-dir=C:\Users\Alice\AppData\Local\DBX\EBWebView"; + let scrubbed = replace_ci(text, r"C:\USERS\Alice\AppData\Local", "local_appdata"); + assert_eq!(scrubbed, r"--user-data-dir=local_appdata\DBX\EBWebView"); + assert_eq!(replace_ci("abc", "", "x"), "abc"); + assert_eq!(replace_ci("abc ABC", "abc", "z"), "z z"); + } +} diff --git a/src-tauri/src/windows_appcompat.rs b/src-tauri/src/windows_appcompat.rs new file mode 100644 index 0000000000..3dc53cc7cd --- /dev/null +++ b/src-tauri/src/windows_appcompat.rs @@ -0,0 +1,473 @@ +//! Windows AppCompat "Windows 8" layer helper for the WebView2 browser process. +//! +//! WHY: field evidence in `docs/win-av-compat-research.md` (P4, WebView2Feedback #4094): +//! Windows 8 compatibility mode for `msedgewebview2.exe` unblocks WebView2 environment +//! creation where 360 安全卫士 / 奇安信天擎 / Kaspersky intercept the browser process, +//! because the shim engine pushes WebView2 onto legacy API branches that AV hooks only +//! attach to on "modern Windows" code paths. +//! +//! The layer is the per-user value +//! `HKCU\Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers` = +//! full path to `msedgewebview2.exe` -> `~ WIN8RTM`: no admin rights, applied at the next +//! process creation, reversible. Evergreen updates change the version directory, so the +//! ladder rewrites it on every startup ([`set_win8_compat_layer`] resolves the newest +//! runtime). Log-safe strings only carry a source class + version (`startup.log` is emailed +//! to the maintainer). Windows-only module; the pure helpers are Win32-free and testable. + +use std::cmp::Ordering; +use std::path::{Path, PathBuf}; + +use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_FILE_NOT_FOUND, ERROR_INVALID_PARAMETER, ERROR_MORE_DATA, ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, +}; +use windows_sys::Win32::Security::{GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY}; +use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegCreateKeyExW, RegDeleteValueW, RegEnumValueW, RegOpenKeyExW, RegQueryValueExW, RegSetValueExW, + HKEY, HKEY_CURRENT_USER, KEY_QUERY_VALUE, KEY_SET_VALUE, REG_OPTION_NON_VOLATILE, REG_SZ, REG_VALUE_TYPE, +}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + +use crate::windows_appcompat_state::{self, LayerRegistry, LayerValue}; + +/// Per-user AppCompat layer store; the shim engine reads it when a process with a +/// matching full path starts. +const LAYERS_KEY: &str = r"Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers"; +/// Separate DBX-owned key records the exact values created by the recovery +/// chain. Never enumerate the shared Layers key to decide what to remove. +const OWNED_LAYERS_KEY: &str = r"Software\com.dbx.app\StartupCompat\OwnedWebView2Layers"; +/// "Windows 8" mode token; `~` marks the data as a compatibility-layer token list. +const WIN8_LAYER_DATA: &str = "~ WIN8RTM"; +/// `KEY_QUERY_VALUE` keeps `RegCreateKeyExW` working on an already existing key; +/// `KEY_SET_VALUE` is needed to write/delete the layer value. +const LAYERS_KEY_ACCESS: u32 = KEY_QUERY_VALUE | KEY_SET_VALUE; + +/// Evergreen runtime locations and their log-safe source classes, in search order: +/// per-machine 32-bit install, per-machine 64-bit install, then per-user install. +const RUNTIME_LOCATIONS: [(&str, &str); 3] = + [("ProgramFiles(x86)", "program_files_x86"), ("ProgramFiles", "program_files"), ("LOCALAPPDATA", "local_appdata")]; + +/// Enumeration buffer bounds: real layer values are tiny, but a corrupt/foreign value +/// must not be allowed to allocate without limit. +const MAX_VALUE_NAME_CHARS: usize = 16_384; +const MAX_VALUE_DATA_BYTES: usize = 1 << 20; +const INITIAL_VALUE_NAME_CHARS: usize = 256; +const INITIAL_VALUE_DATA_BYTES: usize = 2_048; + +/// Newest installed Evergreen `msedgewebview2.exe` for the current user: the AppCompat +/// layer value name must be the exact path Windows launches, and Evergreen installs one +/// version directory per update. Search order: %ProgramFiles(x86)%, %ProgramFiles%, +/// %LOCALAPPDATA%; highest numeric version wins inside a location; None if absent. +pub(crate) fn find_runtime_browser_executable() -> Option { + for (environment_variable, _) in RUNTIME_LOCATIONS { + let Some(base) = std::env::var_os(environment_variable).filter(|value| !value.is_empty()) else { + continue; + }; + if let Some(executable) = newest_browser_executable_in(&webview_application_directory(&PathBuf::from(base))) { + return Some(executable); + } + } + None +} + +/// Sets the Windows 8 layer only when no user/application layer exists. +/// Ownership is persisted separately for cleanup across recovery processes. +pub(crate) fn set_win8_compat_layer() -> Result { + let Some(executable) = find_runtime_browser_executable() else { + return Err("appcompat win8 layer not set: no WebView2 runtime msedgewebview2.exe found".to_string()); + }; + let registry = WindowsLayerRegistry::open(true) + .map_err(|code| format!("appcompat win8 layer not set: registry open failed (Win32 error {code})"))?; + let value = + LayerValue { value_type: REG_SZ, data: wide(WIN8_LAYER_DATA).into_iter().flat_map(u16::to_le_bytes).collect() }; + let created = windows_appcompat_state::set_if_unconfigured(®istry, &executable.to_string_lossy(), &value) + .map_err(|code| format!("appcompat win8 layer not set: registry update failed (Win32 error {code})"))?; + let version = version_from_executable(&executable).unwrap_or_else(|| "unknown".to_string()); + Ok(format!( + "appcompat win8 layer created={created} existing_layer_preserved={} runtime_source={} version={version}", + !created, + runtime_source_class(&executable) + )) +} + +/// Removes only DBX-created values that have not subsequently been edited. +pub(crate) fn remove_win8_compat_layer() -> Result { + let registry = match WindowsLayerRegistry::open(false) { + Ok(registry) => registry, + Err(ERROR_FILE_NOT_FOUND) => return Ok("appcompat win8 layer not present".to_string()), + Err(code) => return Err(format!("appcompat cleanup registry open failed (Win32 error {code})")), + }; + let count = windows_appcompat_state::remove_owned(®istry) + .map_err(|code| format!("appcompat owned layer cleanup failed (Win32 error {code})"))?; + Ok(format!("appcompat win8 owned layers removed count={count}")) +} + +/// True when the process token is elevated (UAC High or SYSTEM). WHY: the elevated-relaunch +/// profile must not ask for elevation again (pointless UAC prompt / silent failure); any API +/// error reports `false`, so the caller follows the normal path. +pub(crate) fn is_process_elevated() -> bool { + // SAFETY: token/buffer lifetimes are confined to this function; every pointer is + // null-checked before use and the token handle is closed. + unsafe { + let mut token = std::ptr::null_mut(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 || token.is_null() { + return false; + } + let mut elevation = TOKEN_ELEVATION::default(); + let mut returned_length = 0u32; + let status = GetTokenInformation( + token, + TokenElevation, + (&mut elevation as *mut TOKEN_ELEVATION).cast(), + std::mem::size_of::() as u32, + &mut returned_length, + ); + CloseHandle(token); + status != 0 && elevation.TokenIsElevated != 0 + } +} + +// --------------------------------------------------------------------------- +// Runtime discovery (std::fs only) +// --------------------------------------------------------------------------- + +fn webview_application_directory(base: &Path) -> PathBuf { + base.join("Microsoft").join("EdgeWebView").join("Application") +} + +/// Highest-version subdirectory of one `Application` directory that actually contains +/// `msedgewebview2.exe`. Non-version entries (e.g. `SetupMetrics`) are ignored and +/// unreadable directories simply yield `None`. +fn newest_browser_executable_in(application_directory: &Path) -> Option { + let entries = std::fs::read_dir(application_directory).ok()?; + let mut newest: Option<(String, PathBuf)> = None; + for entry in entries { + let Ok(entry) = entry else { continue }; + if !entry.file_type().map(|file_type| file_type.is_dir()).unwrap_or(false) { + continue; + } + let directory_name = entry.file_name().to_string_lossy().into_owned(); + if parse_version(&directory_name).is_none() { + continue; + } + let executable = entry.path().join("msedgewebview2.exe"); + if !executable.is_file() { + continue; + } + let is_newest = match &newest { + None => true, + // Both names parsed as versions above, so this comparison is always Some. + Some((newest_version, _)) => compare_versions(&directory_name, newest_version) == Some(Ordering::Greater), + }; + if is_newest { + newest = Some((directory_name, executable)); + } + } + newest.map(|(_, executable)| executable) +} + +/// Parse a dotted, purely numeric directory name such as `133.0.3065.92`. +fn parse_version(name: &str) -> Option> { + let mut components = Vec::new(); + for component in name.split('.') { + if component.is_empty() { + return None; + } + components.push(component.parse::().ok()?); + } + (!components.is_empty()).then_some(components) +} + +/// Numeric component-wise version order; missing trailing components count as 0 +/// (`1.2` == `1.2.0`, `1.2.10` > `1.2.9`). `None` when either side is not numeric. +fn compare_versions(left: &str, right: &str) -> Option { + let left = parse_version(left)?; + let right = parse_version(right)?; + for index in 0..left.len().max(right.len()) { + match left.get(index).copied().unwrap_or(0).cmp(&right.get(index).copied().unwrap_or(0)) { + Ordering::Equal => continue, + ordering => return Some(ordering), + } + } + Some(Ordering::Equal) +} + +/// Version string for the log line: the version directory's name when it parses. +fn version_from_executable(executable: &Path) -> Option { + let directory_name = executable.parent()?.file_name()?.to_string_lossy().into_owned(); + parse_version(&directory_name).map(|_| directory_name) +} + +/// Log-safe source class for the discovered executable (never the path itself). +fn runtime_source_class(executable: &Path) -> &'static str { + let candidates: Vec<(&'static str, PathBuf)> = RUNTIME_LOCATIONS + .iter() + .filter_map(|(environment_variable, class)| { + let base = std::env::var_os(environment_variable).filter(|value| !value.is_empty())?; + Some((*class, webview_application_directory(&PathBuf::from(base)))) + }) + .collect(); + classify_runtime_source(executable, &candidates) +} + +/// Pure classification helper: the first candidate root containing the executable wins, +/// otherwise `custom`. Parameterised for unit tests. +fn classify_runtime_source(executable: &Path, candidates: &[(&'static str, PathBuf)]) -> &'static str { + candidates + .iter() + .find(|(_, root)| path_starts_with_ci(executable, root)) + .map(|(class, _)| *class) + .unwrap_or("custom") +} + +/// Windows paths are case-insensitive, so prefix matching normalizes separators and ASCII +/// case. Used only for log classification, never for registry access. +fn path_starts_with_ci(path: &Path, root: &Path) -> bool { + let normalize = + |value: &Path| value.to_string_lossy().replace('/', "\\").trim_end_matches('\\').to_ascii_lowercase(); + let normalized_path = normalize(path); + let normalized_root = normalize(root); + if normalized_root.is_empty() { + return false; + } + normalized_path == normalized_root || normalized_path.starts_with(&format!("{normalized_root}\\")) +} + +// --------------------------------------------------------------------------- +// Registry helpers +// --------------------------------------------------------------------------- + +/// Open (optionally creating) the per-user `AppCompatFlags\Layers` key. Errors are +/// returned as raw Win32 codes so callers can special-case "key absent". +fn open_registry_key(path: &str, create: bool) -> Result { + let path = wide(path); + let mut key: HKEY = std::ptr::null_mut(); + // SAFETY: `path` is NUL-terminated and outlives the call; optional class/security + // pointers are null, and `key` is only used when the API reports success. + let status = unsafe { + if create { + RegCreateKeyExW( + HKEY_CURRENT_USER, + path.as_ptr(), + 0, + std::ptr::null(), + REG_OPTION_NON_VOLATILE, + LAYERS_KEY_ACCESS, + std::ptr::null(), + &mut key, + std::ptr::null_mut(), + ) + } else { + RegOpenKeyExW(HKEY_CURRENT_USER, path.as_ptr(), 0, LAYERS_KEY_ACCESS, &mut key) + } + }; + if status != ERROR_SUCCESS { + return Err(status); + } + if key.is_null() { + return Err(ERROR_INVALID_PARAMETER); + } + Ok(key) +} + +struct WindowsLayerRegistry { + layers: HKEY, + owned: HKEY, +} + +impl WindowsLayerRegistry { + fn open(create_ownership: bool) -> Result { + let owned = open_registry_key(OWNED_LAYERS_KEY, create_ownership)?; + match open_registry_key(LAYERS_KEY, true) { + Ok(layers) => Ok(Self { layers, owned }), + Err(code) => { + unsafe { RegCloseKey(owned) }; + Err(code) + } + } + } +} + +impl Drop for WindowsLayerRegistry { + fn drop(&mut self) { + // Both handles were opened by this instance and are no longer used. + unsafe { + RegCloseKey(self.layers); + RegCloseKey(self.owned); + } + } +} + +impl LayerRegistry for WindowsLayerRegistry { + type Error = u32; + + fn current(&self, name: &str) -> Result, u32> { + read_registry_value(self.layers, name) + } + fn write(&self, name: &str, value: &LayerValue) -> Result<(), u32> { + write_registry_value(self.layers, name, value) + } + fn delete(&self, name: &str) -> Result<(), u32> { + delete_registry_value(self.layers, name) + } + fn owned(&self) -> Result, u32> { + owned_registry_values(self.owned) + } + fn remember(&self, name: &str, value: &LayerValue) -> Result<(), u32> { + write_registry_value(self.owned, name, value) + } + fn forget(&self, name: &str) -> Result<(), u32> { + delete_registry_value(self.owned, name) + } +} + +fn write_registry_value(key: HKEY, name: &str, value: &LayerValue) -> Result<(), u32> { + let name = wide(name); + // Buffers outlive the call; registry lengths are expressed in bytes. + let status = unsafe { + RegSetValueExW(key, name.as_ptr(), 0, value.value_type, value.data.as_ptr(), value.data.len() as u32) + }; + if status == ERROR_SUCCESS { + Ok(()) + } else { + Err(status) + } +} + +fn delete_registry_value(key: HKEY, name: &str) -> Result<(), u32> { + let name = wide(name); + let status = unsafe { RegDeleteValueW(key, name.as_ptr()) }; + if status == ERROR_SUCCESS || status == ERROR_FILE_NOT_FOUND { + Ok(()) + } else { + Err(status) + } +} + +fn read_registry_value(key: HKEY, name: &str) -> Result, u32> { + let name = wide(name); + let mut value_type = 0; + let mut size = 0; + let status = unsafe { + RegQueryValueExW(key, name.as_ptr(), std::ptr::null(), &mut value_type, std::ptr::null_mut(), &mut size) + }; + if status == ERROR_FILE_NOT_FOUND { + return Ok(None); + } + if status != ERROR_SUCCESS { + return Err(status); + } + if size as usize > MAX_VALUE_DATA_BYTES { + return Err(ERROR_MORE_DATA); + } + let mut data = vec![0; size as usize]; + let status = unsafe { + RegQueryValueExW(key, name.as_ptr(), std::ptr::null(), &mut value_type, data.as_mut_ptr(), &mut size) + }; + if status == ERROR_FILE_NOT_FOUND { + return Ok(None); + } + if status != ERROR_SUCCESS { + return Err(status); + } + data.truncate(size as usize); + Ok(Some(LayerValue { value_type, data })) +} + +/// Enumerate the separate DBX ownership key, never the user's shared Layers. +fn owned_registry_values(key: HKEY) -> Result, u32> { + let mut values = Vec::new(); + let mut index = 0u32; + let mut name_capacity = INITIAL_VALUE_NAME_CHARS; + let mut data_capacity = INITIAL_VALUE_DATA_BYTES; + loop { + let mut name = vec![0u16; name_capacity]; + let mut data = vec![0u8; data_capacity]; + let mut name_length = name_capacity as u32; + let mut data_length = data_capacity as u32; + let mut value_type: REG_VALUE_TYPE = 0; + let status = unsafe { + RegEnumValueW( + key, + index, + name.as_mut_ptr(), + &mut name_length, + std::ptr::null(), + &mut value_type, + data.as_mut_ptr(), + &mut data_length, + ) + }; + match status { + ERROR_SUCCESS => { + let name_length = (name_length as usize).min(name.len()); + let value_name = String::from_utf16_lossy(&name[..name_length]); + data.truncate((data_length as usize).min(data.len())); + values.push((value_name, LayerValue { value_type, data })); + index += 1; + name_capacity = INITIAL_VALUE_NAME_CHARS; + data_capacity = INITIAL_VALUE_DATA_BYTES; + } + ERROR_MORE_DATA => { + // Value-name lengths exclude the terminating NUL. + let required_name = name_length as usize + 1; + let required_data = (data_length as usize).max(1); + if required_name > MAX_VALUE_NAME_CHARS || required_data > MAX_VALUE_DATA_BYTES { + return Err(ERROR_MORE_DATA); + } + if required_name <= name_capacity && required_data <= data_capacity { + return Err(ERROR_MORE_DATA); + } + name_capacity = name_capacity.max(required_name); + data_capacity = data_capacity.max(required_data); + } + ERROR_NO_MORE_ITEMS => return Ok(values), + other => return Err(other), + } + } +} + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +/// NUL-terminated wide string for Win32 registry APIs. +fn wide(text: &str) -> Vec { + text.encode_utf16().chain(std::iter::once(0)).collect() +} + +// --------------------------------------------------------------------------- +// Tests: pure helpers only (Windows CI runs these; no registry/runtime required). +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compares_numeric_version_directories() { + assert_eq!(compare_versions("133.0.3065.92", "130.0.2849.68"), Some(Ordering::Greater)); + assert_eq!(compare_versions("130.0.2849.68", "133.0.3065.92"), Some(Ordering::Less)); + assert_eq!(compare_versions("133.0.3065.92", "133.0.3065.92"), Some(Ordering::Equal)); + // Numeric, not lexicographic (100 > 92); missing trailing components count as 0. + assert_eq!(compare_versions("133.0.3065.100", "133.0.3065.92"), Some(Ordering::Greater)); + assert_eq!(compare_versions("1.2", "1.2.0"), Some(Ordering::Equal)); + assert_eq!(compare_versions("1.2.1", "1.2"), Some(Ordering::Greater)); + // Non-numeric names are rejected. + assert_eq!(compare_versions("EBWebView", "133.0.3065.92"), None); + assert_eq!(parse_version(""), None); + } + + #[test] + fn classifies_runtime_sources_without_leaking_paths() { + let candidates = [ + ("program_files_x86", PathBuf::from(r"C:\Program Files (x86)\Microsoft\EdgeWebView\Application")), + ("program_files", PathBuf::from(r"C:\Program Files\Microsoft\EdgeWebView\Application")), + ("local_appdata", PathBuf::from(r"C:\Users\alice\AppData\Local\Microsoft\EdgeWebView\Application")), + ]; + let executable = |root: &Path| root.join("133.0.3065.92").join("msedgewebview2.exe"); + assert_eq!(classify_runtime_source(&executable(&candidates[0].1), &candidates), "program_files_x86"); + assert_eq!(classify_runtime_source(&executable(&candidates[1].1), &candidates), "program_files"); + assert_eq!(classify_runtime_source(&executable(&candidates[2].1), &candidates), "local_appdata"); + assert_eq!(classify_runtime_source(Path::new(r"D:\Portable\msedgewebview2.exe"), &candidates), "custom"); + } +} diff --git a/src-tauri/src/windows_appcompat_noop.rs b/src-tauri/src/windows_appcompat_noop.rs new file mode 100644 index 0000000000..974a0a0d80 --- /dev/null +++ b/src-tauri/src/windows_appcompat_noop.rs @@ -0,0 +1,20 @@ +//! Non-Windows stubs for the AppCompat compatibility-layer helpers. +//! +//! The real implementation lives in `windows_appcompat.rs` and is only compiled +//! on Windows; these no-ops keep the startup-compatibility ladder compiling on +//! other hosts (and in host-side unit tests) without `#[cfg]` noise at call +//! sites. + +#![allow(dead_code)] + +pub(crate) fn set_win8_compat_layer() -> Result { + Err("appcompat win8 layer unsupported on this platform".to_string()) +} + +pub(crate) fn remove_win8_compat_layer() -> Result { + Ok("appcompat win8 layer unsupported on this platform".to_string()) +} + +pub(crate) fn is_process_elevated() -> bool { + false +} diff --git a/src-tauri/src/windows_appcompat_state.rs b/src-tauri/src/windows_appcompat_state.rs new file mode 100644 index 0000000000..ca7a806e42 --- /dev/null +++ b/src-tauri/src/windows_appcompat_state.rs @@ -0,0 +1,158 @@ +//! Ownership rules for the startup compatibility registry changes. +//! Kept independent of Win32 so the same rules are tested on every CI host. + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LayerValue { + pub value_type: u32, + pub data: Vec, +} + +pub(crate) trait LayerRegistry { + type Error; + + fn current(&self, name: &str) -> Result, Self::Error>; + fn write(&self, name: &str, value: &LayerValue) -> Result<(), Self::Error>; + fn delete(&self, name: &str) -> Result<(), Self::Error>; + fn owned(&self) -> Result, Self::Error>; + fn remember(&self, name: &str, value: &LayerValue) -> Result<(), Self::Error>; + fn forget(&self, name: &str) -> Result<(), Self::Error>; +} + +/// Never overwrite a pre-existing user/application setting. Record only values +/// we create, before writing them, so ownership survives a recovery relaunch. +pub(crate) fn set_if_unconfigured( + registry: &R, + name: &str, + value: &LayerValue, +) -> Result { + if registry.current(name)?.is_some() { + return Ok(false); + } + registry.remember(name, value)?; + // Re-check after recording ownership in case another app set a layer. + if registry.current(name)?.is_some() { + registry.forget(name)?; + return Ok(false); + } + if let Err(error) = registry.write(name, value) { + let _ = registry.forget(name); + return Err(error); + } + Ok(true) +} + +/// Remove only DBX-owned values that still equal what DBX wrote. Other apps' +/// paths and subsequent user edits are left in place, including their type. +pub(crate) fn remove_owned(registry: &R) -> Result { + let mut removed = 0; + for (name, expected) in registry.owned()? { + if registry.current(&name)?.as_ref() == Some(&expected) { + registry.delete(&name)?; + removed += 1; + } + registry.forget(&name)?; + } + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::collections::BTreeMap; + + #[derive(Default)] + struct Registry { + layers: RefCell>, + owned: RefCell>, + fail_write: bool, + } + + impl LayerRegistry for Registry { + type Error = &'static str; + + fn current(&self, name: &str) -> Result, Self::Error> { + Ok(self.layers.borrow().get(name).cloned()) + } + fn write(&self, name: &str, value: &LayerValue) -> Result<(), Self::Error> { + if self.fail_write { + return Err("write failed"); + } + self.layers.borrow_mut().insert(name.to_string(), value.clone()); + Ok(()) + } + fn delete(&self, name: &str) -> Result<(), Self::Error> { + self.layers.borrow_mut().remove(name); + Ok(()) + } + fn owned(&self) -> Result, Self::Error> { + Ok(self.owned.borrow().iter().map(|(name, value)| (name.clone(), value.clone())).collect()) + } + fn remember(&self, name: &str, value: &LayerValue) -> Result<(), Self::Error> { + self.owned.borrow_mut().insert(name.to_string(), value.clone()); + Ok(()) + } + fn forget(&self, name: &str) -> Result<(), Self::Error> { + self.owned.borrow_mut().remove(name); + Ok(()) + } + } + + fn win8() -> LayerValue { + LayerValue { value_type: 1, data: b"~ WIN8RTM".to_vec() } + } + + #[test] + fn preexisting_layers_are_preserved_and_never_claimed() { + for value in [win8(), LayerValue { value_type: 2, data: b"~ HIGHDPIAWARE".to_vec() }] { + let registry = Registry::default(); + registry.write("runtime", &value).unwrap(); + assert!(!set_if_unconfigured(®istry, "runtime", &win8()).unwrap()); + assert_eq!(remove_owned(®istry), Ok(0)); + assert_eq!(registry.current("runtime").unwrap(), Some(value)); + assert!(registry.owned().unwrap().is_empty()); + } + } + + #[test] + fn cleanup_only_removes_owned_values_even_when_foreign_values_match() { + let registry = Registry::default(); + registry.write("other-app/msedgewebview2.exe", &win8()).unwrap(); + assert!(set_if_unconfigured(®istry, "dbx-runtime", &win8()).unwrap()); + assert_eq!(remove_owned(®istry), Ok(1)); + assert_eq!(registry.current("dbx-runtime").unwrap(), None); + assert_eq!(registry.current("other-app/msedgewebview2.exe").unwrap(), Some(win8())); + } + + #[test] + fn cleanup_preserves_later_edits_including_registry_type_changes() { + for changed in [ + LayerValue { value_type: 1, data: b"~ WIN8RTM HIGHDPIAWARE".to_vec() }, + LayerValue { value_type: 2, ..win8() }, + ] { + let registry = Registry::default(); + set_if_unconfigured(®istry, "runtime", &win8()).unwrap(); + registry.write("runtime", &changed).unwrap(); + assert_eq!(remove_owned(®istry), Ok(0)); + assert_eq!(registry.current("runtime").unwrap(), Some(changed)); + assert!(registry.owned().unwrap().is_empty()); + } + } + + #[test] + fn failed_writes_do_not_claim_ownership() { + let registry = Registry { fail_write: true, ..Registry::default() }; + assert_eq!(set_if_unconfigured(®istry, "runtime", &win8()), Err("write failed")); + assert!(registry.owned().unwrap().is_empty()); + assert_eq!(registry.current("runtime").unwrap(), None); + } + + #[test] + fn repeated_startup_keeps_ownership_for_later_cleanup() { + let registry = Registry::default(); + assert!(set_if_unconfigured(®istry, "runtime", &win8()).unwrap()); + assert!(!set_if_unconfigured(®istry, "runtime", &win8()).unwrap()); + assert_eq!(remove_owned(®istry), Ok(1)); + assert_eq!(remove_owned(®istry), Ok(0)); + } +} diff --git a/vendor/wry/DBX-PATCH.md b/vendor/wry/DBX-PATCH.md index 0d44bed0f3..59f7b3b442 100644 --- a/vendor/wry/DBX-PATCH.md +++ b/vendor/wry/DBX-PATCH.md @@ -37,3 +37,16 @@ DBX pushes its dynamic toolbar-aligned position through the new method so the on the same explicit placement. Without it, the stored static inset and AppKit layout disagree on every resize pass (see upstream tauri-apps/wry#1747 and tauri-apps/tauri#15451). + +On Windows, DBX also makes the two documented WebView2 runtime environment +overrides effective: `WEBVIEW2_USER_DATA_FOLDER` replaces the WebContext user +data directory for the next environment creation, and +`WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` is merged into the arguments that are +actually handed to `CreateCoreWebView2EnvironmentWithOptions` (the WebView2 +loader ignores the variable when the options object sets arguments, and wry +always sets them). `--disable-features` / `--enable-features` lists are unioned +instead of replaced. `WEBVIEW2_BROWSER_EXECUTABLE_FOLDER` now also selects a +fixed runtime on non-Windows-7 targets. This is what makes the runtime startup +compatibility profiles in `src-tauri/src/startup_recovery.rs` able to retry a +launch with different WebView2 arguments without rebuilding the app; without the +merge those profiles were no-ops against wry's default options. diff --git a/vendor/wry/src/webview2/mod.rs b/vendor/wry/src/webview2/mod.rs index 4268a8a395..c2155a8c3a 100644 --- a/vendor/wry/src/webview2/mod.rs +++ b/vendor/wry/src/webview2/mod.rs @@ -286,14 +286,22 @@ impl InnerWebView { attributes: &WebViewAttributes, pl_attrs: super::PlatformSpecificWebViewAttributes, ) -> Result { - let data_directory = attributes - .context - .as_deref() - .and_then(|context| context.data_directory()) - .map(HSTRING::from); + // DBX patch: the WebView2 user data folder can be redirected at runtime + // through `WEBVIEW2_USER_DATA_FOLDER`. The startup compatibility ladder + // uses this to retry a blocked launch with a fresh user data directory + // (AV products and broken sandbox profiles often leave the default folder + // unusable). Upstream wry only lets `WebContext::new` decide this, which + // cannot change between startup attempts. + let data_directory = user_data_folder_from_environment().or_else(|| { + attributes + .context + .as_deref() + .and_then(|context| context.data_directory()) + .map(HSTRING::from) + }); // additional browser args - let additional_browser_args = pl_attrs.additional_browser_args.unwrap_or_else(|| { + let configured_browser_args = pl_attrs.additional_browser_args.unwrap_or_else(|| { // remove "mini menu" - See https://github.com/tauri-apps/wry/issues/535 // and "smart screen" - See https://github.com/tauri-apps/tauri/issues/1345 let default_args = "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection"; @@ -323,6 +331,19 @@ impl InnerWebView { arguments }); + // DBX patch: merge `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` into the browser + // arguments that are actually passed to + // `CreateCoreWebView2EnvironmentWithOptions`. The WebView2 loader only + // reads that environment variable when the additional arguments option is + // *not* set, and wry always sets it, so without this merge every runtime + // env override (notably the `--no-sandbox` / `--disable-gpu` / + // `--disable-features=RendererCodeIntegrity` set used by DBX's enterprise + // startup compatibility profiles) was silently ignored. + let additional_browser_args = match additional_browser_args_from_environment() { + Some(extra) => merge_browser_arguments(&configured_browser_args, &extra), + None => configured_browser_args, + }; + let (tx, rx) = mpsc::channel(); let options = CoreWebView2EnvironmentOptions::default(); unsafe { @@ -1929,7 +1950,76 @@ fn configured_browser_executable_folder() -> Option { version.major, version.minor, ); - browser_executable_folder_from(folder.as_deref(), fixed_runtime_enabled) + // DBX patch: an explicitly configured fixed runtime (env var) wins on any + // Windows version. The startup compatibility ladder can point at a + // self-contained WebView2 runtime when the machine-wide Evergreen runtime is + // hooked by a security product; upstream wry only honored the variable on the + // Windows 7 target. + browser_executable_folder_from(folder.as_deref(), fixed_runtime_enabled || folder.is_some()) +} + +/// DBX patch: additional browser arguments supplied through the documented +/// `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` environment variable. +fn additional_browser_args_from_environment() -> Option { + std::env::var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS") + .ok() + .filter(|value| !value.trim().is_empty()) +} + +/// DBX patch: user data folder supplied through +/// `WEBVIEW2_USER_DATA_FOLDER`, when non-empty. +fn user_data_folder_from_environment() -> Option { + std::env::var_os("WEBVIEW2_USER_DATA_FOLDER") + .filter(|value| !value.is_empty()) + .map(HSTRING::from) +} + +/// DBX patch: merges two Chromium command lines kept in single strings. +/// +/// `--disable-features` / `--enable-features` switches replace each other in +/// Chromium instead of accumulating, so both lists are unioned (base order +/// first); every other switch is appended when not already present. Arguments +/// containing embedded whitespace are not supported, which matches the fixed +/// flag set DBX's startup compatibility ladder uses. +fn merge_browser_arguments(base: &str, extra: &str) -> String { + use std::collections::BTreeMap; + + let mut switches: Vec = Vec::new(); + let mut feature_lists: BTreeMap<&'static str, Vec> = BTreeMap::new(); + + for argument in base.split_whitespace().chain(extra.split_whitespace()) { + let (feature_kind, features) = if let Some(features) = argument.strip_prefix("--disable-features=") { + (Some("--disable-features"), features) + } else if let Some(features) = argument.strip_prefix("--enable-features=") { + (Some("--enable-features"), features) + } else { + (None, "") + }; + + match feature_kind { + Some(feature_kind) => { + let merged = feature_lists.entry(feature_kind).or_default(); + for feature in features.split(',').map(str::trim).filter(|feature| !feature.is_empty()) { + if !merged.iter().any(|existing| existing == feature) { + merged.push(feature.to_string()); + } + } + } + None => { + if !switches.iter().any(|existing| existing == argument) { + switches.push(argument.to_string()); + } + } + } + } + + for (feature_kind, features) in feature_lists { + if !features.is_empty() { + switches.push(format!("{feature_kind}={}", features.join(","))); + } + } + + switches.join(" ") } /// Builds a structured [`WebView2ProcessFailedInfo`] from the WebView2 event @@ -2108,6 +2198,53 @@ mod tests { assert_eq!(value.to_string_lossy(), folder.to_string_lossy()); } + #[test] + fn merge_browser_arguments_appends_environment_switches() { + let merged = merge_browser_arguments( + "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection", + "--no-sandbox --disable-gpu", + ); + assert_eq!( + merged, + "--no-sandbox --disable-gpu --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection" + ); + } + + #[test] + fn merge_browser_arguments_unions_feature_lists_instead_of_replacing_them() { + let merged = merge_browser_arguments( + "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --no-sandbox", + "--disable-features=RendererCodeIntegrity,msSmartScreenProtection --disable-gpu", + ); + let arguments = merged.split(' ').collect::>(); + assert_eq!( + arguments.iter().filter(|argument| argument.starts_with("--disable-features=")).count(), + 1 + ); + assert!(merged.contains("--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection,RendererCodeIntegrity")); + assert!(merged.contains("--no-sandbox")); + assert!(merged.contains("--disable-gpu")); + } + + #[test] + fn merge_browser_arguments_deduplicates_identical_switches() { + let merged = merge_browser_arguments("--no-sandbox --disable-gpu", "--disable-gpu --no-sandbox"); + assert_eq!(merged, "--no-sandbox --disable-gpu"); + } + + #[test] + fn merge_browser_arguments_keeps_enable_features_separate_from_disable_features() { + let merged = merge_browser_arguments("--enable-features=A", "--disable-features=B"); + assert_eq!(merged, "--enable-features=A --disable-features=B"); + } + + #[test] + fn merge_browser_arguments_handles_empty_inputs() { + assert_eq!(merge_browser_arguments("--no-sandbox", " "), "--no-sandbox"); + assert_eq!(merge_browser_arguments("", "--no-sandbox"), "--no-sandbox"); + assert_eq!(merge_browser_arguments("", ""), ""); + } + #[test] fn process_failed_kind_maps_all_raw_kinds() { assert_eq!(