From 6e8cee32d66e269fa4b1338cf9fe06abf5aa075e Mon Sep 17 00:00:00 2001 From: std-external Date: Mon, 12 Oct 2026 08:57:17 +0800 Subject: [PATCH 1/4] fix(startup): surface startup failures instead of dying silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failing plugin initialization aborts `Builder::run` before the first window exists; release builds run with `windows_subsystem = "windows"` and a hidden console, so the `.expect` panic left the user with no window, no dialog and nothing but one line in the log. - report a failed `run` with a native error dialog that shows the raw error, the log path and a button to open the log folder - probe the cookie cache of `tauri-plugin-http` before registering it: that plugin creates `/.cookies` while the app starts and a machine where the path is not writable (antivirus, ACLs, redirected %LOCALAPPDATA%) fails with "拒绝访问。 (os error 5)"; repair a stale cookie file, otherwise start without the plugin instead of taking the whole launcher down - install the WebView2 Evergreen Bootstrapper directly when the runtime is missing, and keep the log path in the prompt - keep the five previous runs as celemod.1.log … celemod.5.log instead of truncating the only log, and log version/exe/cwd/os at the very start - add an `open_log_folder` command (right click on the title bar version) --- src-tauri/src/backend.rs | 218 +++++++++++++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/logging.rs | 156 +++++++++++++ src-tauri/src/native_dialog.rs | 97 ++++++++ src-tauri/src/webview_runtime.rs | 124 ++++++---- .../src/components/WindowTitlebar.tsx | 4 +- src/celemod-ui/src/tauri/commands.ts | 1 + 7 files changed, 539 insertions(+), 62 deletions(-) create mode 100644 src-tauri/src/native_dialog.rs diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs index d445732..ff2d415 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,31 @@ 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(directory) => { + crate::logging::error(format_args!( + "CeleMod cannot write its cache directory {}; starting without network features", + directory.display() + )); + crate::native_dialog::error( + "CeleMod - 缓存目录不可写", + &format!( + "CeleMod 无法读写自己的缓存目录,网络功能(Mod 列表、下载、更新)这次被跳过。\n\n\ + 常见原因是安全软件、系统权限或组策略拦住了它:\n\ + 可以先删除这个目录后重新打开,或把 CeleMod 加入安全软件白名单。\n\n\ + 目录:{}\n日志:{}", + directory.display(), + crate::logging::path().display() + ), + ); + false + } + }; #[cfg(target_os = "android")] { builder = builder.plugin(crate::android::init()); @@ -6351,7 +6376,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 +6420,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 +6489,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 +6512,178 @@ 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() + } +} + +/// 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. +/// +/// Returns the directory on success; on failure it drops a stale cookie file +/// once and retries, and otherwise returns the directory as `Err`. +fn prepare_http_cache(directory: PathBuf) -> Result { + let cookies = directory.join(".cookies"); + let probe = || -> std::io::Result<()> { + fs::create_dir_all(&directory)?; + drop( + fs::File::options() + .create(true) + .append(true) + .read(true) + .open(&cookies)?, + ); + Ok(()) + }; + + if probe().is_ok() { + return Ok(directory); + } + // A stale cookie file is the only thing we can safely drop on our own. + let _ = fs::remove_file(&cookies); + if probe().is_ok() { + return Ok(directory); + } + Err(directory) +} + +#[cfg(test)] +mod http_plugin_cache_tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn fixture() -> PathBuf { + static NEXT: AtomicUsize = AtomicUsize::new(0); + std::env::temp_dir().join(format!( + "celemod-http-cache-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )) + } + + #[test] + fn creates_the_directory_and_cookie_file() { + let directory = fixture(); + let result = prepare_http_cache(directory.clone()); + assert_eq!(result.expect("cache is writable"), directory); + assert!(directory.join(".cookies").is_file()); + let _ = fs::remove_dir_all(&directory); + } + + #[test] + fn reports_a_directory_that_cannot_hold_the_cookie_file() { + let directory = fixture(); + fs::create_dir_all(directory.join(".cookies")).unwrap(); + let error = prepare_http_cache(directory.clone()).expect_err("cache is not writable"); + assert_eq!(error, directory); + let _ = fs::remove_dir_all(&directory); + } + + /// A symlink in place of the cookie file cannot be opened, but removing it + /// makes the directory usable again — the one repair we can do ourselves. + #[cfg(unix)] + #[test] + fn repairs_a_stale_cookie_entry() { + let directory = fixture(); + fs::create_dir_all(&directory).unwrap(); + fs::create_dir_all(directory.join("stale")).unwrap(); + std::os::unix::fs::symlink(directory.join("stale"), directory.join(".cookies")).unwrap(); + + let result = prepare_http_cache(directory.clone()); + + assert_eq!(result.expect("cache is repaired"), directory); + assert!(directory.join(".cookies").is_file()); + let _ = fs::remove_dir_all(&directory); + } +} + +/// 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() {