fix(windows): 区分 WebView2 运行时问题与安全软件拦截,新增有界兼容启动阶梯 (#13036)

* 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>
This commit is contained in:
zipgandzipg authored and GitHub committed 2026-10-23 01:10:48 +08:00
1 parent 75e984a6e8
commit bc0734ab25
12 files changed
+3793 -315

No files matched your search

Generated
+1
View File
@@ -2203,6 +2203,7 @@ dependencies = [
"tokio-util", "tokio-util",
"uuid", "uuid",
"webview2-com", "webview2-com",
"windows 0.61.3",
"windows-core 0.61.2", "windows-core 0.61.2",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"wry", "wry",
+6 -2
View File
@@ -98,9 +98,13 @@ gtk = "0.18"
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
# 与 vendored wry / tauri-runtime-wry 同版本(Cargo.lock 只有一份 0.38.x),用于在 # 与 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" 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, # `cast`(Settings → Settings3 的 QueryInterface)来自 windows-core 的 Interface trait,
# 版本与 vendored wry / webview2-com-sys 一致。 # 版本与 vendored wry / webview2-com-sys 一致。
windows-core = "0.61" 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"] }
+95
View File
@@ -11,15 +11,26 @@ mod migration_gate;
mod models; mod models;
mod plugin_ui_protocol; mod plugin_ui_protocol;
#[cfg(any(target_os = "windows", test))] #[cfg(any(target_os = "windows", test))]
mod startup_messages;
#[cfg(any(target_os = "windows", test))]
mod startup_recovery; mod startup_recovery;
#[cfg(all(not(target_os = "windows"), not(test)))] #[cfg(all(not(target_os = "windows"), not(test)))]
#[path = "startup_recovery_noop.rs"] #[path = "startup_recovery_noop.rs"]
mod startup_recovery; mod startup_recovery;
#[cfg(any(target_os = "windows", test))] #[cfg(any(target_os = "windows", test))]
mod webview2_legacy_css; mod webview2_legacy_css;
#[cfg(target_os = "windows")]
mod webview2_preflight;
#[cfg(any(target_os = "windows", test))] #[cfg(any(target_os = "windows", test))]
mod webview2_recovery; mod webview2_recovery;
mod window_state_guard; 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 commands::connection::AppState;
use dbx_core::persistence::secret_codec::SecretKeyPolicy; use dbx_core::persistence::secret_codec::SecretKeyPolicy;
@@ -641,21 +652,26 @@ fn apply_linux_webkit_rendering_workarounds() {
fn show_main_window<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> bool { fn show_main_window<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> bool {
let Some(window) = app.get_webview_window("main") else { let Some(window) = app.get_webview_window("main") else {
eprintln!("[WINDOW] show_main_window: no \"main\" webview window to reveal"); eprintln!("[WINDOW] show_main_window: no \"main\" webview window to reveal");
startup_recovery::record("show_main_window: main webview window is missing");
return false; return false;
}; };
if let Err(err) = window.show() { if let Err(err) = window.show() {
eprintln!("[WINDOW] show_main_window: show() failed: {err}"); eprintln!("[WINDOW] show_main_window: show() failed: {err}");
startup_recovery::record(format!("show_main_window: show() failed: {err}"));
} }
if let Err(err) = window.unminimize() { if let Err(err) = window.unminimize() {
eprintln!("[WINDOW] show_main_window: unminimize() failed: {err}"); 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() { if let Err(err) = window.set_focus() {
eprintln!("[WINDOW] show_main_window: set_focus() failed: {err}"); eprintln!("[WINDOW] show_main_window: set_focus() failed: {err}");
startup_recovery::record(format!("show_main_window: set_focus() failed: {err}"));
} }
match window.is_visible() { match window.is_visible() {
Ok(visible) => visible, Ok(visible) => visible,
Err(err) => { Err(err) => {
eprintln!("[WINDOW] show_main_window: is_visible() failed: {err}"); eprintln!("[WINDOW] show_main_window: is_visible() failed: {err}");
startup_recovery::record(format!("show_main_window: is_visible() failed: {err}"));
false false
} }
} }
@@ -1729,6 +1745,17 @@ fn route_external_commands(
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { 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 // Metadata/completion command chains nest very large async futures and can
// exhaust tokio's default 2 MiB worker stack, which aborts the process with // 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 // STATUS_STACK_OVERFLOW. Share the roomier stack the backup worker and Web
@@ -1741,6 +1768,68 @@ pub fn run() {
startup_recovery::initialize(); startup_recovery::initialize();
rustls::crypto::aws_lc_rs::default_provider().install_default().expect("Failed to install rustls crypto provider"); rustls::crypto::aws_lc_rs::default_provider().install_default().expect("Failed to install rustls crypto provider");
append_startup_probe("runtime prerequisites configured"); 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")] #[cfg(target_os = "linux")]
apply_linux_webkit_rendering_workarounds(); apply_linux_webkit_rendering_workarounds();
@@ -1849,6 +1938,9 @@ pub fn run() {
"early main window show requested; {}", "early main window show requested; {}",
main_window_probe_state(app.handle()) 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"); append_startup_probe("resolving app data dir");
@@ -2062,6 +2154,9 @@ pub fn run() {
"final main window show requested; {}", "final main window show requested; {}",
main_window_probe_state(app.handle()) 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"))] #[cfg(any(windows, target_os = "linux"))]
let _ = app.deep_link().register_all(); let _ = app.deep_link().register_all();
+147
View File
@@ -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_<version>_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());
}
}
}
}
File diff suppressed because it is too large. Load diff
+14
View File
@@ -6,4 +6,18 @@ pub(crate) fn mark_frontend_ready(_main_window_visible: bool) {}
pub(crate) fn record_run_event() {} 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<R: tauri::Runtime>(_app: &tauri::AppHandle<R>) -> bool {
false
}
pub(crate) fn start_watchdog<R: tauri::Runtime>(_app: &tauri::AppHandle<R>) {} pub(crate) fn start_watchdog<R: tauri::Runtime>(_app: &tauri::AppHandle<R>) {}
#[allow(dead_code)]
pub(crate) fn handle_missing_webview2_runtime() {}
#[allow(dead_code)]
pub(crate) fn handle_damaged_webview2_runtime(_runtime_version: Option<&str>) {}
File diff suppressed because it is too large. Load diff
+473
View File
@@ -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<PathBuf> {
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<String, String> {
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(&registry, &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<String, String> {
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(&registry)
.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::<TOKEN_ELEVATION>() 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<PathBuf> {
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<Vec<u64>> {
let mut components = Vec::new();
for component in name.split('.') {
if component.is_empty() {
return None;
}
components.push(component.parse::<u64>().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<Ordering> {
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<String> {
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<HKEY, u32> {
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<Self, u32> {
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<Option<LayerValue>, 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<Vec<(String, LayerValue)>, 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<Option<LayerValue>, 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<Vec<(String, LayerValue)>, 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<u16> {
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");
}
}
+20
View File
@@ -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<String, String> {
Err("appcompat win8 layer unsupported on this platform".to_string())
}
pub(crate) fn remove_win8_compat_layer() -> Result<String, String> {
Ok("appcompat win8 layer unsupported on this platform".to_string())
}
pub(crate) fn is_process_elevated() -> bool {
false
}
+158
View File
@@ -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<u8>,
}
pub(crate) trait LayerRegistry {
type Error;
fn current(&self, name: &str) -> Result<Option<LayerValue>, Self::Error>;
fn write(&self, name: &str, value: &LayerValue) -> Result<(), Self::Error>;
fn delete(&self, name: &str) -> Result<(), Self::Error>;
fn owned(&self) -> Result<Vec<(String, LayerValue)>, 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<R: LayerRegistry>(
registry: &R,
name: &str,
value: &LayerValue,
) -> Result<bool, R::Error> {
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<R: LayerRegistry>(registry: &R) -> Result<u32, R::Error> {
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<BTreeMap<String, LayerValue>>,
owned: RefCell<BTreeMap<String, LayerValue>>,
fail_write: bool,
}
impl LayerRegistry for Registry {
type Error = &'static str;
fn current(&self, name: &str) -> Result<Option<LayerValue>, 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<Vec<(String, LayerValue)>, 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(&registry, "runtime", &win8()).unwrap());
assert_eq!(remove_owned(&registry), 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(&registry, "dbx-runtime", &win8()).unwrap());
assert_eq!(remove_owned(&registry), 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(&registry, "runtime", &win8()).unwrap();
registry.write("runtime", &changed).unwrap();
assert_eq!(remove_owned(&registry), 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(&registry, "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(&registry, "runtime", &win8()).unwrap());
assert!(!set_if_unconfigured(&registry, "runtime", &win8()).unwrap());
assert_eq!(remove_owned(&registry), Ok(1));
assert_eq!(remove_owned(&registry), Ok(0));
}
}
+13
View File
@@ -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 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 layout disagree on every resize pass (see upstream tauri-apps/wry#1747 and
tauri-apps/tauri#15451). 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.
+144 -7
View File
@@ -286,14 +286,22 @@ impl InnerWebView {
attributes: &WebViewAttributes, attributes: &WebViewAttributes,
pl_attrs: super::PlatformSpecificWebViewAttributes, pl_attrs: super::PlatformSpecificWebViewAttributes,
) -> Result<ICoreWebView2Environment> { ) -> Result<ICoreWebView2Environment> {
let data_directory = attributes // DBX patch: the WebView2 user data folder can be redirected at runtime
.context // through `WEBVIEW2_USER_DATA_FOLDER`. The startup compatibility ladder
.as_deref() // uses this to retry a blocked launch with a fresh user data directory
.and_then(|context| context.data_directory()) // (AV products and broken sandbox profiles often leave the default folder
.map(HSTRING::from); // 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 // 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 // remove "mini menu" - See https://github.com/tauri-apps/wry/issues/535
// and "smart screen" - See https://github.com/tauri-apps/tauri/issues/1345 // and "smart screen" - See https://github.com/tauri-apps/tauri/issues/1345
let default_args = "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection"; let default_args = "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection";
@@ -323,6 +331,19 @@ impl InnerWebView {
arguments 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 (tx, rx) = mpsc::channel();
let options = CoreWebView2EnvironmentOptions::default(); let options = CoreWebView2EnvironmentOptions::default();
unsafe { unsafe {
@@ -1929,7 +1950,76 @@ fn configured_browser_executable_folder() -> Option<HSTRING> {
version.major, version.major,
version.minor, 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<String> {
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<HSTRING> {
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<String> = Vec::new();
let mut feature_lists: BTreeMap<&'static str, Vec<String>> = 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 /// Builds a structured [`WebView2ProcessFailedInfo`] from the WebView2 event
@@ -2108,6 +2198,53 @@ mod tests {
assert_eq!(value.to_string_lossy(), folder.to_string_lossy()); 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::<Vec<_>>();
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] #[test]
fn process_failed_kind_maps_all_raw_kinds() { fn process_failed_kind_maps_all_raw_kinds() {
assert_eq!( assert_eq!(