diff --git a/Cargo.lock b/Cargo.lock index d031f7286..74c49e282 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1146,6 +1146,7 @@ dependencies = [ "regex", "reqwest 0.13.4", "reqwest_dav", + "rfd", "runas", "rustls", "scopeguard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d54770060..10e05ce26 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -60,6 +60,7 @@ bitflags = { workspace = true } socket2 = { workspace = true } warp = { version = "0.4.3", features = ["server"] } open = "5.4.1" +rfd = { version = "0.16.0", default-features = false, features = ["common-controls-v6"] } dunce = "1.0.5" nanoid = "0.5" chrono = { workspace = true } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bd83206d0..655bd7514 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -31,11 +31,10 @@ mod app_init { use super::*; /// Initialize singleton monitoring for other instances - pub fn init_singleton_check() -> Result<()> { + pub fn init_singleton_check() -> Result { AsyncHandler::block_on(async move { logging!(info, Type::Setup, "开始检查单例实例..."); - server::check_singleton().await?; - Ok(()) + server::check_singleton().await }) } @@ -213,17 +212,40 @@ mod app_init { } } -pub fn run() { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StartupAction { + Continue, + ExitSuccess, + ExitFailure, +} + +fn handle_singleton_startup( + result: Result, + report_error: impl FnOnce(&anyhow::Error), +) -> StartupAction { + match result { + Ok(server::SingletonDisposition::Primary) => StartupAction::Continue, + Ok(server::SingletonDisposition::Secondary) => StartupAction::ExitSuccess, + Err(error) => { + report_error(&error); + StartupAction::ExitFailure + } + } +} + +pub fn run() -> std::process::ExitCode { #[cfg(all(target_os = "macos", not(debug_assertions), not(test), not(feature = "verge-dev")))] if utils::macos_launch_guard::enforce_before_initialization() == utils::macos_launch_guard::LaunchDisposition::Exit { - return; + return std::process::ExitCode::SUCCESS; } let _ = utils::dirs::init_portable_flag(); - if app_init::init_singleton_check().is_err() { - return; + match handle_singleton_startup(app_init::init_singleton_check(), utils::startup::report_error) { + StartupAction::Continue => {} + StartupAction::ExitSuccess => return std::process::ExitCode::SUCCESS, + StartupAction::ExitFailure => return std::process::ExitCode::FAILURE, } #[cfg(target_os = "linux")] @@ -483,4 +505,30 @@ pub fn run() { }, _ => {} }); + std::process::ExitCode::SUCCESS +} + +#[cfg(test)] +mod startup_tests { + use super::{StartupAction, handle_singleton_startup}; + use crate::utils::server::SingletonDisposition; + use std::cell::Cell; + + #[test] + fn secondary_instance_exits_quietly_and_successfully() { + let reported = Cell::new(false); + let action = handle_singleton_startup(Ok(SingletonDisposition::Secondary), |_| reported.set(true)); + + assert_eq!(action, StartupAction::ExitSuccess); + assert!(!reported.get()); + } + + #[test] + fn fatal_singleton_error_is_reported_and_fails_startup() { + let reported = Cell::new(false); + let action = handle_singleton_startup(Err(anyhow::anyhow!("listener failed")), |_| reported.set(true)); + + assert_eq!(action, StartupAction::ExitFailure); + assert!(reported.get()); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index bd942f5f3..e1509c02e 100755 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,8 +1,11 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::{ + process::ExitCode, + sync::atomic::{AtomicUsize, Ordering}, +}; -fn main() { +fn main() -> ExitCode { let default_parallelism = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); let worker_limit = std::cmp::min(default_parallelism, 8); let blocking_limit = 2 * worker_limit; @@ -25,5 +28,5 @@ fn main() { #[cfg(feature = "tokio-trace")] console_subscriber::init(); - app_lib::run(); + app_lib::run() } diff --git a/src-tauri/src/utils/mod.rs b/src-tauri/src/utils/mod.rs index 65af1430a..1a4ba0669 100644 --- a/src-tauri/src/utils/mod.rs +++ b/src-tauri/src/utils/mod.rs @@ -16,6 +16,7 @@ pub mod schtasks; pub mod server; pub mod singleton; pub mod speed; +pub(crate) mod startup; pub mod tmpl; #[cfg(target_os = "macos")] pub mod tray_speed; diff --git a/src-tauri/src/utils/server.rs b/src-tauri/src/utils/server.rs index 6c043838b..bc2782b82 100644 --- a/src-tauri/src/utils/server.rs +++ b/src-tauri/src/utils/server.rs @@ -42,16 +42,23 @@ static COMMANDS_READY: AtomicBool = AtomicBool::new(false); #[cfg(feature = "verge-dev")] static DEV_QUIT_REQUESTED: AtomicBool = AtomicBool::new(false); -pub async fn check_singleton() -> Result<()> { - let record_path = instance_record_path()?; - let lock = open_instance_lock(&record_path.with_file_name(INSTANCE_LOCK_FILE))?; - if !try_lock_instance(&lock)? { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SingletonDisposition { + Primary, + Secondary, +} + +pub async fn check_singleton() -> Result { + let record_path = instance_record_path().context("failed to resolve singleton instance record path")?; + let lock = open_instance_lock(&record_path.with_file_name(INSTANCE_LOCK_FILE)) + .context("failed to initialize singleton lock")?; + if !try_lock_instance(&lock).context("failed to acquire singleton lock")? { let deadline = std::time::Instant::now() + Duration::from_secs(20); while std::time::Instant::now() < deadline { if let Ok(record) = read_instance_record(&record_path) && notify_existing_instance(&record).await { - bail!("app exists"); + return Ok(SingletonDisposition::Secondary); } tokio::time::sleep(Duration::from_millis(50)).await; } @@ -59,19 +66,24 @@ pub async fn check_singleton() -> Result<()> { } let preferred = read_instance_record(&record_path).ok().map(|record| record.port); - let listener = bind_primary_listener(preferred).await?; - let port = listener.local_addr()?.port(); + let listener = bind_primary_listener(preferred) + .await + .context("failed to reserve embedded server port")?; + let port = listener + .local_addr() + .context("failed to read embedded server address")? + .port(); let record = InstanceRecord { port, - token: random_token()?, + token: random_token().context("failed to generate singleton instance token")?, }; - write_instance_record(&record_path, &record)?; + write_instance_record(&record_path, &record).context("failed to persist singleton instance record")?; INSTANCE_LOCK .set(lock) .map_err(|_| anyhow::anyhow!("singleton lock is already initialized"))?; let _ = EMBEDDED_PORT.set(port); start_embedded_server(listener, record.token); - Ok(()) + Ok(SingletonDisposition::Primary) } async fn bind_primary_listener(preferred: Option) -> Result { diff --git a/src-tauri/src/utils/startup.rs b/src-tauri/src/utils/startup.rs new file mode 100644 index 000000000..507943c4a --- /dev/null +++ b/src-tauri/src/utils/startup.rs @@ -0,0 +1,85 @@ +use crate::utils::dirs; +use anyhow::{Context as _, Result}; +use chrono::Local; +use std::{ + fs::OpenOptions, + io::Write as _, + path::{Path, PathBuf}, +}; + +const STARTUP_LOG_FILE: &str = "startup.log"; + +pub(crate) fn report_error(error: &anyhow::Error) { + let detail = format!("{error:#}"); + eprintln!("[clash-verge] startup failed: {detail}"); + + let log_result = startup_log_path().and_then(|path| { + append_error(&path, &detail)?; + Ok(path) + }); + let message = match log_result { + Ok(path) => format!( + "Clash Verge could not start.\n\n{detail}\n\nDiagnostic log:\n{}", + path.display() + ), + Err(log_error) => { + eprintln!("[clash-verge] failed to write startup log: {log_error:#}"); + format!( + "Clash Verge could not start.\n\n{detail}\n\nThe diagnostic log could not be written:\n{log_error:#}" + ) + } + }; + + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title("Clash Verge startup failed") + .set_description(message) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} + +fn startup_log_path() -> Result { + Ok(dirs::preinit_app_data_dir()?.join("logs").join(STARTUP_LOG_FILE)) +} + +fn append_error(path: &Path, detail: &str) -> Result<()> { + let parent = path.parent().context("startup log has no parent directory")?; + std::fs::create_dir_all(parent).context("failed to create startup log directory")?; + + let mut options = OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = options.open(path).context("failed to open startup log")?; + writeln!( + file, + "[{}] ERROR [Startup] {detail}", + Local::now().format("%Y-%m-%d %H:%M:%S%.3f") + ) + .context("failed to write startup log")?; + file.flush().context("failed to flush startup log")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::append_error; + + #[test] + fn startup_log_is_created_and_appended() -> anyhow::Result<()> { + let root = std::env::temp_dir().join(format!("startup-error-log-{}", std::process::id())); + let path = root.join("nested/startup.log"); + + append_error(&path, "first failure")?; + append_error(&path, "second failure")?; + + let content = std::fs::read_to_string(&path)?; + assert!(content.contains("ERROR [Startup] first failure")); + assert!(content.contains("ERROR [Startup] second failure")); + std::fs::remove_dir_all(root)?; + Ok(()) + } +}