diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs index d445732..392563c 100644 --- a/src-tauri/src/backend.rs +++ b/src-tauri/src/backend.rs @@ -4830,6 +4830,11 @@ fn show_log_window() -> Result<(), String> { }) } +#[tauri::command] +fn open_log_folder() -> Result<(), String> { + reveal_log_folder() +} + #[tauri::command] fn write_frontend_log(level: String, message: String) { crate::logging::frontend(&level, &message); @@ -6304,11 +6309,6 @@ pub fn run() { return; } - crate::logging::info(format_args!( - "CeleMod v{} ({})", - env!("VERSION"), - env!("GIT_HASH") - )); let startup_game_paths = if is_test_mode() { vec![get_test_game_path()] } else { @@ -6335,6 +6335,35 @@ pub fn run() { } } let mut builder = tauri::Builder::default(); + let context = tauri::generate_context!(); + let identifier = context.config().identifier.clone(); + // A plugin that cannot initialize must not take the whole launcher down + // before it is able to show anything (see `http_plugin_cache`). + let http_available = match http_plugin_cache(&identifier) { + Ok(_) => true, + Err(failure) => { + crate::logging::error(format_args!( + "CeleMod cannot write its cache directory: {}; starting without network features", + failure.reason + )); + crate::native_dialog::error( + "CeleMod - 缓存目录不可写", + &format!( + "CeleMod 无法读写自己的缓存目录,这次跳过了联网检查更新、Mod 站搜索、\ + Everest/Loenn 版本查询(Mod 列表、下载、启动游戏不受影响)。\n\n\ + 失败的操作:{}\n\n\ + 常见原因是安全软件拦写、目录权限或属主异常、\ + %LOCALAPPDATA% 被重定向或策略限制:\n\ + 可以先删除下面的目录后重新打开,或把 CeleMod 加入安全软件白名单。\n\n\ + 目录:{}\n日志:{}", + failure.reason, + failure.directory.display(), + crate::logging::path().display() + ), + ); + false + } + }; #[cfg(target_os = "android")] { builder = builder.plugin(crate::android::init()); @@ -6351,7 +6380,10 @@ pub fn run() { focus_main_window(app); })); } - builder + if http_available { + builder = builder.plugin(tauri_plugin_http::init()); + } + let result = builder .plugin(tauri_plugin_deep_link::init()) .setup(|app| { #[cfg(any(windows, target_os = "linux"))] @@ -6392,7 +6424,6 @@ pub fn run() { Ok(()) }) - .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ take_pending_deep_links, @@ -6462,6 +6493,7 @@ pub fn run() { normalize_game_path, get_mod_latest_info, show_log_window, + open_log_folder, write_frontend_log, set_catalog_offline, is_catalog_offline, @@ -6484,6 +6516,346 @@ pub fn run() { keybindings::update_key_binding, keybindings::save_touch_buttons, ]) - .run(tauri::generate_context!()) - .expect("error while running CeleMod"); + .run(context); + + if let Err(error) = result { + report_startup_failure(&error, &identifier); + } +} + +/// Reports a startup failure that happened before the first window existed. +/// +/// Release builds run with `windows_subsystem = "windows"` and a hidden console, +/// so a failed `run` used to be a silent no-op for the user: no window, no +/// dialog, nothing but a line in the log file. +fn report_startup_failure(error: &tauri::Error, identifier: &str) { + crate::logging::error(format_args!("CeleMod failed to start: {error}")); + let message = format!( + "CeleMod 启动失败,界面没有创建出来。\n\n{error}{}\n\n\ + 日志:{}\n\n点「是」打开日志文件夹,把最新的 celemod.log 发出来就能定位问题。", + startup_failure_hint(error, identifier), + crate::logging::path().display() + ); + if crate::native_dialog::confirm("CeleMod - 启动失败", &message) + && let Err(error) = reveal_log_folder() + { + crate::logging::error(format_args!("Failed to open the log folder: {error}")); + } +} + +/// Extra advice for the failures users actually run into. +fn startup_failure_hint(error: &tauri::Error, identifier: &str) -> String { + let text = error.to_string(); + let access_denied = text.contains("os error 5") + || text.contains("拒绝访问") + || text.contains("Access is denied"); + if access_denied { + format!( + "\n\n提示:这通常是安全软件、系统权限或组策略拦住了 CeleMod 读写自己的目录。\n\ + 可以先删除 %LOCALAPPDATA%\\{identifier} 后重新打开,\n\ + 或把 CeleMod 加入安全软件白名单。" + ) + } else { + String::new() + } +} + +/// Why the cookie cache of `tauri-plugin-http` cannot be used. +#[derive(Debug)] +struct HttpCacheFailure { + directory: PathBuf, + /// Which operation failed, with the operating system message. + reason: String, +} + +/// Resolves the directory `tauri-plugin-http` uses for its cookie cache. +/// +/// The plugin creates (`create_dir_all`) and opens `/.cookies` while +/// the app starts, and Tauri turns an error there into a fatal +/// `PluginInitialization("http", …)` — on Windows a machine where that path is +/// not writable (antivirus, ACLs, redirected `%LOCALAPPDATA%`) fails with +/// "拒绝访问。 (os error 5)" and the launcher dies without any UI. +/// +/// The path is probed first so the caller can start without the plugin instead +/// of dying; see `prepare_http_cache`. +fn http_plugin_cache(identifier: &str) -> Result { + let Some(directory) = dirs::cache_dir().map(|dir| dir.join(identifier)) else { + // Without a cache directory we cannot probe; let the plugin report it. + return Ok(PathBuf::new()); + }; + prepare_http_cache(directory) +} + +/// Creates and opens the cookie file exactly like `tauri-plugin-http` does, and +/// makes sure the plugin will not start from a store it cannot use. +/// +/// The plugin only fails on `create_dir_all` and on opening `.cookies`; a file +/// it can open but not parse is silently dropped ("empty store"). Both are +/// handled here: a cookie file that cannot be opened is moved aside and +/// recreated, and one the plugin cannot parse is backed up for inspection. +fn prepare_http_cache(directory: PathBuf) -> Result { + let cookies = directory.join(".cookies"); + if let Err(reason) = probe_http_cache_with_retries(&directory, &cookies) { + let disposition = move_cookie_file_aside(&cookies); + return match probe_http_cache_with_retries(&directory, &cookies) { + Ok(()) => { + crate::logging::warn(format_args!( + "Replaced the unusable cookie file {} ({reason}); it was {disposition}", + cookies.display() + )); + Ok(directory) + } + Err(retry) => Err(HttpCacheFailure { + directory, + reason: format!("{reason}; after {disposition}: {retry}"), + }), + }; + } + + if let Some(reason) = cookie_store_problem(&cookies) { + let disposition = move_cookie_file_aside(&cookies); + if let Err(error) = fs::write(&cookies, "") { + crate::logging::warn(format_args!( + "Failed to recreate the cookie file {}: {error}", + cookies.display() + )); + } + crate::logging::warn(format_args!( + "Cookie file {} is not a usable cookie store ({reason}); it was {disposition}", + cookies.display() + )); + } + Ok(directory) +} + +/// Antivirus and sync clients hold a file exclusively for a moment while they +/// scan it, and that also fails the open; do not repair (or degrade) on the +/// first refusal. +const HTTP_CACHE_PROBE_ATTEMPTS: usize = 3; +const HTTP_CACHE_PROBE_RETRY_DELAY: Duration = Duration::from_millis(200); + +fn probe_http_cache_with_retries(directory: &Path, cookies: &Path) -> Result<(), String> { + let mut last_error = String::new(); + for attempt in 1..=HTTP_CACHE_PROBE_ATTEMPTS { + match probe_http_cache(directory, cookies) { + Ok(()) => return Ok(()), + Err(reason) => last_error = reason, + } + if attempt < HTTP_CACHE_PROBE_ATTEMPTS { + std::thread::sleep(HTTP_CACHE_PROBE_RETRY_DELAY); + } + } + Err(last_error) +} + +/// The two file operations the plugin performs while the app starts. +fn probe_http_cache(directory: &Path, cookies: &Path) -> Result<(), String> { + fs::create_dir_all(directory) + .map_err(|error| format!("create {} ({error})", directory.display()))?; + fs::File::options() + .create(true) + .append(true) + .read(true) + .open(cookies) + .map(|_| ()) + .map_err(|error| format!("open {} ({error})", cookies.display())) +} + +/// The plugin loads its store as JSON (`cookie_store 0.22`) and falls back to an +/// empty store without a word when that fails, so a file it cannot parse is +/// reported here instead of being dropped silently. +fn cookie_store_problem(cookies: &Path) -> Option { + let bytes = match fs::read(cookies) { + Ok(bytes) => bytes, + Err(error) => return Some(format!("it cannot be read ({error})")), + }; + if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + return Some("it starts with a UTF-8 BOM, which the cookie store never writes".to_string()); + } + if bytes.iter().all(u8::is_ascii_whitespace) { + return None; + } + match serde_json::from_slice::(&bytes) { + Ok(_) => None, + Err(error) => Some(format!("it is not valid JSON ({error})")), + } +} + +/// Moves the cookie file out of the way. A read-only file cannot be opened for +/// writing on Windows — nor deleted — so the attribute is cleared first. +/// Returns what happened, for the log. +fn move_cookie_file_aside(cookies: &Path) -> String { + if let Ok(metadata) = fs::metadata(cookies) { + let mut permissions = metadata.permissions(); + if permissions.readonly() { + permissions.set_readonly(false); + let _ = fs::set_permissions(cookies, permissions); + } + } + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis()) + .unwrap_or_default(); + let backup = cookies.with_extension(format!("bad-{timestamp}")); + match fs::rename(cookies, &backup) { + Ok(()) => format!("moved to {}", backup.display()), + Err(rename_error) => match fs::remove_file(cookies) { + Ok(()) => format!("removed ({rename_error})"), + Err(remove_error) => { + format!("neither moved ({rename_error}) nor removed ({remove_error})") + } + }, + } +} + +#[cfg(test)] +mod http_plugin_cache_tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct Fixture(PathBuf); + + impl Fixture { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "celemod-http-cache-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + Self(path) + } + + fn cookies(&self) -> PathBuf { + self.0.join(".cookies") + } + + /// The backup `move_cookie_file_aside` leaves behind, if any. + fn backup(&self) -> Option { + fs::read_dir(&self.0) + .ok()? + .flatten() + .map(|entry| entry.path()) + .find(|path| { + path.file_name() + .map(|name| name.to_string_lossy().starts_with(".cookies.bad-")) + .unwrap_or(false) + }) + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn creates_the_directory_and_cookie_file() { + let fixture = Fixture::new(); + let directory = prepare_http_cache(fixture.0.clone()).expect("cache is writable"); + assert_eq!(directory, fixture.0); + assert!(fixture.cookies().is_file()); + assert!(fixture.backup().is_none()); + } + + #[test] + fn keeps_a_readable_cookie_store() { + let fixture = Fixture::new(); + fs::create_dir_all(&fixture.0).unwrap(); + fs::write(fixture.cookies(), "[]").unwrap(); + + prepare_http_cache(fixture.0.clone()).expect("cache is writable"); + + assert_eq!( + fs::read_to_string(fixture.cookies()).unwrap(), + "[]", + "a usable store is left alone" + ); + assert!(fixture.backup().is_none()); + } + + /// A cookie file the plugin cannot open is what kills the launcher, so it is + /// moved aside and recreated (a directory works as an unopenable file on + /// every platform). + #[test] + fn replaces_a_cookie_file_that_cannot_be_opened() { + let fixture = Fixture::new(); + fs::create_dir_all(fixture.cookies()).unwrap(); + + let directory = prepare_http_cache(fixture.0.clone()).expect("cache is repaired"); + + assert_eq!(directory, fixture.0); + assert!(fixture.cookies().is_file()); + assert!( + fixture.backup().is_some(), + "the old file is kept as a backup" + ); + } + + /// The plugin silently drops a store it cannot parse; keep a copy instead. + #[test] + fn backs_up_a_cookie_store_that_cannot_be_parsed() { + let fixture = Fixture::new(); + fs::create_dir_all(&fixture.0).unwrap(); + let bom_only = [0xEFu8, 0xBB, 0xBF]; + fs::write(fixture.cookies(), bom_only).unwrap(); + + prepare_http_cache(fixture.0.clone()).expect("cache is writable"); + + assert_eq!(fs::read(fixture.cookies()).unwrap(), Vec::::new()); + let backup = fixture.backup().expect("the broken store is kept"); + assert_eq!(fs::read(backup).unwrap(), bom_only); + } + + #[test] + fn reports_a_directory_that_cannot_be_used_at_all() { + let fixture = Fixture::new(); + fs::write(&fixture.0, "not a directory").unwrap(); + + let failure = prepare_http_cache(fixture.0.clone()).expect_err("cache is not writable"); + + assert_eq!(failure.directory, fixture.0); + assert!( + failure.reason.contains("create"), + "the failing operation is reported: {}", + failure.reason + ); + } +} + +/// Reveals the log file in the file manager; the folder also holds the logs of +/// the previous runs (`celemod.1.log` …). +fn reveal_log_folder() -> Result<(), String> { + let log = crate::logging::path(); + let directory = log.parent().map(Path::to_path_buf).unwrap_or_default(); + + #[cfg(target_os = "windows")] + let result = std::process::Command::new("explorer") + .arg(if log.is_file() { + format!("/select,{}", log.display()) + } else { + directory.display().to_string() + }) + .spawn() + .map(|_| ()) + .map_err(|error| error.to_string()); + #[cfg(target_os = "macos")] + let result = std::process::Command::new("open") + .args(["-R", &log.display().to_string()]) + .spawn() + .map(|_| ()) + .map_err(|error| error.to_string()); + #[cfg(all(unix, not(target_os = "macos")))] + let result = open::that(&directory).map_err(|error| error.to_string()); + + result.map_err(|error| { + let message = format!( + "Failed to open the log folder {}: {error}", + directory.display() + ); + crate::logging::error(format_args!("{message}")); + message + }) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4531474..7f40cf5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod installer_progress; #[cfg(any(target_os = "android", test))] mod log_viewer; mod logging; +mod native_dialog; mod webview_runtime; pub use backend::run; diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs index 76a82d0..5c7238e 100644 --- a/src-tauri/src/logging.rs +++ b/src-tauri/src/logging.rs @@ -7,6 +7,9 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; +/// How many previous runs are kept next to the current log file. +const MAX_PREVIOUS_LOGS: usize = 5; + static LOG_FILE: OnceLock>> = OnceLock::new(); static LOG_PATH: OnceLock = OnceLock::new(); @@ -16,11 +19,48 @@ fn default_log_path() -> PathBuf { .unwrap_or_else(|| PathBuf::from("celemod.log")) } +/// `celemod.log` → `celemod.1.log` → … → `celemod.{MAX_PREVIOUS_LOGS}.log` +fn rotated_path(path: &Path, index: usize) -> PathBuf { + let stem = path + .file_stem() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "celemod".to_string()); + let extension = path + .extension() + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "log".to_string()); + path.with_file_name(format!("{stem}.{index}.{extension}")) +} + +/// Moves the log of the previous run aside instead of deleting it: a crash that +/// only happens on the first launch after an update is otherwise gone before +/// anyone can look at it. +fn rotate(path: &Path) { + let size = fs::metadata(path) + .map(|metadata| metadata.len()) + .unwrap_or_default(); + if size == 0 { + // The previous run died before writing anything worth keeping. + let _ = fs::remove_file(path); + return; + } + + let _ = fs::remove_file(rotated_path(path, MAX_PREVIOUS_LOGS)); + for index in (1..MAX_PREVIOUS_LOGS).rev() { + let previous = rotated_path(path, index); + if previous.is_file() { + let _ = fs::rename(&previous, rotated_path(path, index + 1)); + } + } + let _ = fs::rename(path, rotated_path(path, 1)); +} + pub(crate) fn initialize() { let path = default_log_path(); if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); } + rotate(&path); let file = OpenOptions::new() .create(true) .write(true) @@ -30,6 +70,15 @@ pub(crate) fn initialize() { let _ = LOG_PATH.set(path); let _ = LOG_FILE.set(Mutex::new(file)); + // Identify this run as early as possible: the webview check and a failing + // plugin initialization both abort before the backend would log anything. + info(format_args!( + "CeleMod v{} ({})", + env!("VERSION"), + env!("GIT_HASH") + )); + info(format_args!("{}", run_context())); + let previous_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { error(format_args!("panic: {panic_info}")); @@ -37,6 +86,22 @@ pub(crate) fn initialize() { })); } +fn run_context() -> String { + let mut parts = Vec::new(); + if let Ok(exe) = std::env::current_exe() { + parts.push(format!("exe: {}", exe.display())); + } + if let Ok(cwd) = std::env::current_dir() { + parts.push(format!("cwd: {}", cwd.display())); + } + parts.push(format!( + "os: {} {}", + std::env::consts::OS, + std::env::consts::ARCH + )); + parts.join(" | ") +} + pub(crate) fn path() -> &'static Path { LOG_PATH.get_or_init(default_log_path).as_path() } @@ -86,3 +151,94 @@ pub(crate) fn frontend(level: &str, message: &str) { }; write(level, "frontend", format_args!("{message}")); } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct Fixture(PathBuf); + + impl Fixture { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "celemod-logging-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn log(&self) -> PathBuf { + self.0.join("celemod.log") + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn older_runs_are_kept_and_the_oldest_is_dropped() { + let fixture = Fixture::new(); + let log = fixture.log(); + for index in 1..=MAX_PREVIOUS_LOGS { + fs::write( + rotated_path(&log, index), + format!("run-{}", MAX_PREVIOUS_LOGS + 1 - index), + ) + .unwrap(); + } + fs::write(&log, "current").unwrap(); + + rotate(&log); + + assert!(!log.exists(), "the current log is moved aside"); + assert_eq!( + fs::read_to_string(rotated_path(&log, 1)).unwrap(), + "current" + ); + assert_eq!( + fs::read_to_string(rotated_path(&log, MAX_PREVIOUS_LOGS)).unwrap(), + "run-2" + ); + assert!( + !rotated_path(&log, MAX_PREVIOUS_LOGS + 1).exists(), + "only the recent runs are kept" + ); + } + + #[test] + fn empty_log_is_removed_instead_of_rotated() { + let fixture = Fixture::new(); + let log = fixture.log(); + fs::write(&log, "").unwrap(); + + rotate(&log); + + assert!(!log.exists()); + assert!(!rotated_path(&log, 1).exists()); + } + + #[test] + fn missing_log_does_not_create_history() { + let fixture = Fixture::new(); + let log = fixture.log(); + + rotate(&log); + + assert!(!rotated_path(&log, 1).exists()); + } + + #[test] + fn rotated_names_keep_the_extension_of_the_log() { + assert_eq!( + rotated_path(Path::new("/home/orange/.celemod/celemod.log"), 3), + Path::new("/home/orange/.celemod/celemod.3.log") + ); + } +} diff --git a/src-tauri/src/native_dialog.rs b/src-tauri/src/native_dialog.rs new file mode 100644 index 0000000..5ef64d8 --- /dev/null +++ b/src-tauri/src/native_dialog.rs @@ -0,0 +1,97 @@ +//! Native operating-system dialogs. +//! +//! Startup failures happen before (or instead of) the first window, so the +//! fallback must not depend on the webview: an in-app error page cannot be +//! rendered when the webview runtime, a plugin or the window creation itself +//! failed. All messages are also written to the log file. + +/// Shows a blocking error dialog. Returns `true` when the user picked "是/Yes". +pub(crate) fn confirm(title: &str, message: &str) -> bool { + #[cfg(target_os = "windows")] + { + use winapi::um::winuser::{IDYES, MB_ICONERROR, MB_SETFOREGROUND, MB_YESNO}; + + return windows_message_box(title, message, MB_YESNO | MB_ICONERROR | MB_SETFOREGROUND) + == IDYES; + } + #[cfg(not(target_os = "windows"))] + { + error(title, message); + false + } +} + +/// Shows a blocking error dialog with a single "确定/OK" button. +pub(crate) fn error(title: &str, message: &str) { + #[cfg(target_os = "windows")] + { + use winapi::um::winuser::{MB_ICONERROR, MB_OK, MB_SETFOREGROUND}; + + windows_message_box(title, message, MB_OK | MB_ICONERROR | MB_SETFOREGROUND); + } + #[cfg(target_os = "macos")] + { + let script = format!( + "display alert \"{}\" message \"{}\" as critical", + escape_applescript(title), + escape_applescript(message) + ); + if let Err(error) = std::process::Command::new("osascript") + .args(["-e", &script]) + .status() + { + crate::logging::error(format_args!("{title}: {message} (osascript: {error})")); + } + } + #[cfg(all(unix, not(target_os = "macos")))] + { + let shown = std::process::Command::new("zenity") + .args([ + "--error", + &format!("--title={title}"), + &format!("--text={message}"), + ]) + .status() + .is_ok(); + if !shown { + let _ = std::process::Command::new("kdialog") + .args(["--error", message, "--title", title]) + .status(); + } + crate::logging::error(format_args!("{title}: {message}")); + } + #[cfg(not(any(windows, unix)))] + { + crate::logging::error(format_args!("{title}: {message}")); + } +} + +#[cfg(target_os = "windows")] +fn windows_message_box(title: &str, message: &str, flags: u32) -> i32 { + use std::os::windows::ffi::OsStrExt; + use winapi::um::winuser::MessageBoxW; + + fn wide(value: &str) -> Vec { + std::ffi::OsStr::new(value) + .encode_wide() + .chain(std::iter::once(0)) + .collect() + } + + let message = wide(message); + let title = wide(title); + // SAFETY: both strings are null-terminated and remain alive for the call. + unsafe { + MessageBoxW( + std::ptr::null_mut(), + message.as_ptr(), + title.as_ptr(), + flags, + ) + } +} + +#[cfg(target_os = "macos")] +fn escape_applescript(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} diff --git a/src-tauri/src/webview_runtime.rs b/src-tauri/src/webview_runtime.rs index d3ea62c..566a104 100644 --- a/src-tauri/src/webview_runtime.rs +++ b/src-tauri/src/webview_runtime.rs @@ -19,75 +19,97 @@ pub(crate) fn ensure_available() -> bool { } } +/// Evergreen Bootstrapper (~2 MB) of the WebView2 Runtime. +/// +/// The download section page only lists the installers, so users tend to grab +/// the wrong one; this link downloads the bootstrapper directly. +#[cfg(target_os = "windows")] +const WEBVIEW2_BOOTSTRAPPER_URL: &str = "https://go.microsoft.com/fwlink/p/?LinkId=2124703"; + #[cfg(target_os = "windows")] fn show_missing_runtime_prompt() { - use std::os::windows::ffi::OsStrExt; - use winapi::um::winuser::{IDYES, MB_ICONERROR, MB_SETFOREGROUND, MB_YESNO, MessageBoxW}; + use crate::native_dialog; - const DOWNLOAD_URL: &str = - "https://developer.microsoft.com/en-us/microsoft-edge/webview2/#download-section"; - let title = wide("CeleMod - 缺少 WebView2 Runtime"); - let message = wide( - "CeleMod 需要 Microsoft Edge WebView2 Runtime 才能运行。\n\n是否打开微软官方下载页面?安装完成后请重新启动 CeleMod。", + const TITLE: &str = "CeleMod - 缺少 WebView2 Runtime"; + let message = format!( + "CeleMod 需要 Microsoft Edge WebView2 Runtime 才能运行。\n\n\ + 点「是」自动下载并安装微软官方安装包(约 2 MB,需要联网),\n\ + 装完再重新打开 CeleMod 即可。\n\n\ + 日志:{}", + crate::logging::path().display() ); - // SAFETY: Both strings are null-terminated and remain alive for the call. - let result = unsafe { - MessageBoxW( - std::ptr::null_mut(), - message.as_ptr(), - title.as_ptr(), - MB_YESNO | MB_ICONERROR | MB_SETFOREGROUND, - ) - }; - if result == IDYES - && let Err(error) = open::that(DOWNLOAD_URL) - { + if !native_dialog::confirm(TITLE, &message) { + return; + } + if let Err(error) = install_missing_runtime() { crate::logging::error(format_args!( - "Failed to open the WebView2 download page: {error}" + "Failed to start the WebView2 installer: {error}" )); + native_dialog::error( + TITLE, + "没能自动下载安装包,已为你打开微软官方下载页面。安装完成后请重新启动 CeleMod。", + ); } +} + +/// Downloads the official bootstrapper and starts it; falls back to opening the +/// download link in the browser when anything in between fails. +#[cfg(target_os = "windows")] +fn install_missing_runtime() -> Result<(), String> { + use std::io::Write as _; + + let setup = std::env::temp_dir().join("MicrosoftEdgeWebview2Setup.exe"); + let download = || -> Result<(), String> { + let response = ::ureq::get(WEBVIEW2_BOOTSTRAPPER_URL) + .timeout(std::time::Duration::from_secs(120)) + .call() + .map_err(|error| error.to_string())?; + let mut file = std::fs::File::create(&setup).map_err(|error| error.to_string())?; + std::io::copy(&mut response.into_reader(), &mut file).map_err(|error| error.to_string())?; + file.flush().map_err(|error| error.to_string())?; + Ok(()) + }; + let start = || { + std::process::Command::new(&setup) + .args(["/silent", "/install"]) + .spawn() + .map(|_| ()) + .map_err(|error| error.to_string()) + }; - fn wide(value: &str) -> Vec { - std::ffi::OsStr::new(value) - .encode_wide() - .chain(std::iter::once(0)) - .collect() + match download().and_then(|()| start()) { + Ok(()) => { + crate::logging::info(format_args!( + "Started the WebView2 bootstrapper from {}", + setup.display() + )); + Ok(()) + } + Err(error) => { + crate::logging::warn(format_args!( + "WebView2 bootstrap failed ({error}); opening the download page instead" + )); + open::that(WEBVIEW2_BOOTSTRAPPER_URL) + .map_err(|open_error| format!("{error}; {open_error}")) + } } } #[cfg(target_os = "macos")] fn show_missing_runtime_prompt() { - const MESSAGE: &str = "CeleMod 无法初始化系统 WKWebView。请更新 macOS 后重试。"; - let status = std::process::Command::new("osascript") - .args([ - "-e", - &format!("display alert \"CeleMod 无法启动\" message \"{MESSAGE}\" as critical"), - ]) - .status(); - if status.is_err() { - crate::logging::error(format_args!("{MESSAGE}")); - } + crate::native_dialog::error( + "CeleMod 无法启动", + "CeleMod 无法初始化系统 WKWebView。请更新 macOS 后重试。", + ); } #[cfg(all(unix, not(target_os = "macos")))] fn show_missing_runtime_prompt() { - const MESSAGE: &str = "CeleMod 无法初始化 WebKitGTK。请安装系统的 WebKitGTK 4.1 运行库后重试。"; - - let shown = std::process::Command::new("zenity") - .args([ - "--error", - "--title=CeleMod 无法启动", - &format!("--text={MESSAGE}"), - ]) - .status() - .is_ok(); - if !shown { - let _ = std::process::Command::new("kdialog") - .args(["--error", MESSAGE, "--title", "CeleMod 无法启动"]) - .status(); - } - crate::logging::error(format_args!("{MESSAGE}")); + crate::native_dialog::error( + "CeleMod 无法启动", + "CeleMod 无法初始化 WebKitGTK。请安装系统的 WebKitGTK 4.1 运行库后重试。", + ); } #[cfg(not(any(windows, unix)))] diff --git a/src/celemod-ui/src/components/WindowTitlebar.tsx b/src/celemod-ui/src/components/WindowTitlebar.tsx index dcb6721..64e07f1 100644 --- a/src/celemod-ui/src/components/WindowTitlebar.tsx +++ b/src/celemod-ui/src/components/WindowTitlebar.tsx @@ -67,7 +67,7 @@ export function WindowTitlebar() {