diff --git a/infrastructure/Cargo.toml b/infrastructure/Cargo.toml index 31f55de..2d2c361 100644 --- a/infrastructure/Cargo.toml +++ b/infrastructure/Cargo.toml @@ -6,7 +6,7 @@ authors.workspace = true license.workspace = true [[bin]] -name = "bat-official-sync" +name = "bat" path = "src/bin/bat_official_sync.rs" [dependencies] diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index 4bb752b..7a95316 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -1,11 +1,17 @@ use bat_adapters::official::yostar_jp::PatchPlatform; use bat_infrastructure::{ - OfficialServerInfoSource, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateService, - OfficialUpdateStatus, + OfficialServerInfoSource, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, + OfficialUpdateService, OfficialUpdateStatus, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::env; -use std::path::PathBuf; +use std::fs::{self, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +#[cfg(unix)] +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Condvar, Mutex}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -13,17 +19,23 @@ const EXIT_ERROR: i32 = 1; const EXIT_LOCKED: i32 = 75; const DEFAULT_WATCH_INTERVAL_SECONDS: u64 = 60 * 60; const DEFAULT_ERROR_RETRY_SECONDS: u64 = 60; +const DEFAULT_DAEMON_STATE_DIR: &str = "/tmp/bat-pid"; +const DAEMON_PID_FILE: &str = "bat.pid"; +const DAEMON_STATUS_FILE: &str = "bat-status.json"; +const DAEMON_LOG_FILE: &str = "bat-daemon.log"; +const DAEMON_SOCKET_FILE: &str = "bat.sock"; +const DAEMON_STATUS_VERSION: u32 = 1; const SECONDS_PER_DAY: u64 = 24 * 60 * 60; const BEIJING_UTC_OFFSET_SECONDS: u64 = 8 * 60 * 60; const DAILY_FORCED_REFRESH_LOCAL_SECONDS: [u64; 3] = [3 * 60 * 60, 16 * 60 * 60, 18 * 60 * 60]; const DAILY_FORCED_REFRESH_LABEL: &str = "UTC+8 03:00, 16:00, 18:00"; const STARTUP_BANNER: &str = r#" ============================================================ - ____ _ _ _ _ _____ _ _ _ _ - | __ )| |_ _ ___ / \ _ __ ___| |__ (_)_ _____|_ _|__ ___ | | | _(_) |_ + ____ _ _ _ _ _____ _ _ _ _ + | __ )| |_ _ ___ / \ _ __ ___| |__ (_)_ _____|_ _|__ ___ | | | _(_) |_ | _ \| | | | |/ _ \/ _ \ | '__/ __| '_ \| \ \ / / _ \ | |/ _ \ / _ \| | |/ / | __| | |_) | | |_| | __/ ___ \| | | (__| | | | |\ V / __/ | | (_) | (_) | | <| | |_ - |____/|_|\__,_|\___/_/ \_\_| \___|_| |_|_| \_/ \___| |_|\___/ \___/|_|_|\_\_|\__| + |____/|_|\__,_|\__/_/ \_\_| \___|_| |_|_|\_/ \___/ |_|\___/ \___/|_|_|\_\_|\__| BlueArchiveToolkit Official Resource Sync @@ -32,9 +44,11 @@ const STARTUP_BANNER: &str = r#" fn main() { match run() { - Ok(()) => {} + Ok(exit_code) if exit_code != 0 => std::process::exit(exit_code), + Ok(_) => {} Err(error) => { - let exit_code = if error.to_string().contains("locked") { + let error_message = error.to_string(); + let exit_code = if is_locked_error(&error_message) { EXIT_LOCKED } else { EXIT_ERROR @@ -42,7 +56,7 @@ fn main() { let payload = ErrorReport { status: "error", exit_code, - error: error.to_string(), + error: localize_error_message(&error_message), next_retry_seconds: None, }; eprintln!( @@ -55,23 +69,75 @@ fn main() { } } -fn run() -> anyhow::Result<()> { +fn run() -> anyhow::Result { let options = parse_args()?; - if options.banner { + if matches!( + options.command, + CliCommand::Run | CliCommand::Refresh | CliCommand::Verify | CliCommand::Repair + ) && !options.daemon + && options.banner + { print_startup_banner(); } - if options.watch { - run_watch(options)?; - } else { - let mut logger = ProgressLogger::new(options.progress); - let report = OfficialUpdateService::new().run_with_progress(&options.config, |event| { - logger.log(event); - })?; - if should_print_status(report.update_status, options.quiet_up_to_date) { - println!("{}", serde_json::to_string_pretty(&report)?); + match options.command { + CliCommand::Run => { + if options.daemon { + run_daemon_start(options)?; + } else if options.watch { + run_watch(options)?; + } else { + let mut logger = ProgressLogger::new(options.progress); + let report = + OfficialUpdateService::new().run_with_progress(&options.config, |event| { + logger.log(event); + })?; + if should_print_status(report.update_status, options.quiet_up_to_date) { + print_report(options.output_format, &report)?; + } + } + Ok(0) + } + CliCommand::Status => { + print_daemon_status(&options.state_dir, options.output_format)?; + Ok(0) + } + CliCommand::Stop => { + stop_daemon(&options.state_dir, options.output_format)?; + Ok(0) + } + CliCommand::Restart => { + run_daemon_restart(&options, "restart")?; + Ok(0) + } + CliCommand::Reload => { + run_daemon_restart(&options, "reload")?; + Ok(0) + } + CliCommand::Refresh => { + run_sync_command(&options, "refresh")?; + Ok(0) + } + CliCommand::Verify => { + let healthy = run_verify_command(&options)?; + Ok(if healthy { 0 } else { EXIT_ERROR }) + } + CliCommand::Repair => { + run_sync_command(&options, "repair")?; + Ok(0) + } + CliCommand::Logs => { + run_logs_command(&options)?; + Ok(0) + } + CliCommand::Doctor => { + let healthy = run_doctor_command(&options)?; + Ok(if healthy { 0 } else { EXIT_ERROR }) + } + CliCommand::CleanStable => { + run_clean_stable_command(&options)?; + Ok(0) } } - Ok(()) } #[derive(Debug, Serialize)] @@ -86,60 +152,115 @@ struct ErrorReport<'a> { #[derive(Debug, Clone, PartialEq, Eq)] struct CliOptions { config: OfficialUpdateConfig, + command: CliCommand, + output_format: OutputFormat, watch: bool, + daemon: bool, + daemon_child: bool, + state_dir: PathBuf, + output_explicit: bool, + sync_option_explicit: bool, interval: Duration, error_retry_interval: Duration, quiet_up_to_date: bool, quiet_up_to_date_explicit: bool, progress: bool, banner: bool, + tail_lines: usize, } impl Default for CliOptions { fn default() -> Self { Self { config: OfficialUpdateConfig::default(), + command: CliCommand::Run, + output_format: OutputFormat::Human, watch: false, + daemon: false, + daemon_child: false, + state_dir: PathBuf::from(DEFAULT_DAEMON_STATE_DIR), + output_explicit: false, + sync_option_explicit: false, interval: Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS), error_retry_interval: Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS), quiet_up_to_date: false, quiet_up_to_date_explicit: false, progress: true, banner: true, + tail_lines: 200, } } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutputFormat { + Human, + Json, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CliCommand { + Run, + Status, + Stop, + Restart, + Reload, + Refresh, + Verify, + Repair, + Doctor, + Logs, + CleanStable, +} + fn run_watch(options: CliOptions) -> anyhow::Result<()> { if options.config.dry_run { - return Err(anyhow::anyhow!("watch mode cannot be used with --dry-run")); + return Err(anyhow::anyhow!("watch 常驻模式不能和 --dry-run 同时使用")); } if options.interval.is_zero() { - return Err(anyhow::anyhow!("watch interval must be greater than zero")); + return Err(anyhow::anyhow!("watch 检查间隔必须大于 0")); } if options.error_retry_interval.is_zero() { - return Err(anyhow::anyhow!( - "watch error retry interval must be greater than zero" - )); + return Err(anyhow::anyhow!("watch 失败重试间隔必须大于 0")); } let service = OfficialUpdateService::new(); let mut logger = ProgressLogger::new(options.progress); + let daemon_state_dir = options.state_dir.clone(); + let daemon_control = if options.daemon_child { + Some(new_daemon_control()) + } else { + None + }; + let _rpc_server = if let Some(control) = daemon_control.as_ref() { + Some(start_daemon_rpc_server( + &daemon_state_dir, + Arc::clone(control), + )?) + } else { + None + }; let mut next_forced_refresh_at = next_forced_refresh_at_or_after(SystemTime::now()); let mut pending_scheduled_force = false; + let mut pending_rpc_force = false; logger.log_text( "watch", format!( - "daily forced refresh schedule: {DAILY_FORCED_REFRESH_LABEL}; next forced refresh in {}", + "每日强制刷新时间:{DAILY_FORCED_REFRESH_LABEL};距离下一次强制刷新还有 {}", format_duration(duration_until(next_forced_refresh_at, SystemTime::now())) ), ); loop { + if daemon_control_stop_requested(daemon_control.as_ref()) { + logger.log_text("daemon", "收到停止请求,watch 循环准备退出"); + break; + } + let now = SystemTime::now(); if now >= next_forced_refresh_at { pending_scheduled_force = true; logger.log_text( "watch", - format!("scheduled forced refresh triggered at {DAILY_FORCED_REFRESH_LABEL}"), + format!("已触发固定时间强制刷新:{DAILY_FORCED_REFRESH_LABEL}"), ); next_forced_refresh_at = next_forced_refresh_after(now); } @@ -148,30 +269,69 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> { if pending_scheduled_force { iteration_config.force = true; } + let rpc_force_this_round = pending_rpc_force; + if rpc_force_this_round { + iteration_config.force = true; + pending_rpc_force = false; + } let mut sleep_for = options.interval; logger.log_text( "watch", - if pending_scheduled_force { - "starting watch iteration with scheduled force refresh" + if pending_scheduled_force && rpc_force_this_round { + "开始执行 watch 轮次:本轮为固定时间和 RPC 触发的强制刷新" + } else if pending_scheduled_force { + "开始执行 watch 轮次:本轮为固定时间强制刷新" + } else if rpc_force_this_round { + "开始执行 watch 轮次:本轮为 RPC 触发的强制刷新" } else { - "starting watch iteration" + "开始执行 watch 轮次" }, ); - match service.run_with_progress(&iteration_config, |event| logger.log(event)) { + record_daemon_status( + options.daemon_child, + &daemon_state_dir, + &mut logger, + DaemonStatusUpdate { + state: "running", + last_update_status: None, + last_error: None, + next_retry_seconds: None, + pending_scheduled_force, + next_forced_refresh_at, + }, + ); + match service.run_with_progress_and_cancellation( + &iteration_config, + |event| logger.log(event), + || daemon_control_stop_requested(daemon_control.as_ref()), + ) { Ok(report) => { if pending_scheduled_force { pending_scheduled_force = false; } if should_print_status(report.update_status, options.quiet_up_to_date) { - println!("{}", serde_json::to_string_pretty(&report)?); + print_report(options.output_format, &report)?; } sleep_for = sleep_for.min(duration_until(next_forced_refresh_at, SystemTime::now())); + record_daemon_status( + options.daemon_child, + &daemon_state_dir, + &mut logger, + DaemonStatusUpdate { + state: "sleeping", + last_update_status: Some(report.update_status.as_str().to_string()), + last_error: None, + next_retry_seconds: Some(sleep_for.as_secs()), + pending_scheduled_force, + next_forced_refresh_at, + }, + ); logger.log_text( "watch", format!( - "iteration finished with status={}; next check in {}; next scheduled force refresh in {}", + "本轮完成:状态={};下次检查将在 {} 后执行;距离下一次固定强制刷新还有 {}", report.update_status.as_str(), format_duration(sleep_for), format_duration(duration_until(next_forced_refresh_at, SystemTime::now())) @@ -182,26 +342,2161 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> { sleep_for = options.error_retry_interval; sleep_for = sleep_for.min(duration_until(next_forced_refresh_at, SystemTime::now())); + record_daemon_status( + options.daemon_child, + &daemon_state_dir, + &mut logger, + DaemonStatusUpdate { + state: "error", + last_update_status: None, + last_error: Some(localize_error_message(&error.to_string())), + next_retry_seconds: Some(sleep_for.as_secs()), + pending_scheduled_force, + next_forced_refresh_at, + }, + ); logger.log_text( "watch", format!( - "iteration failed; retrying in {}: {}", + "本轮失败;将在 {} 后重试:{}", format_duration(sleep_for), - error + localize_error_message(&error.to_string()) ), ); let payload = ErrorReport { status: "error", exit_code: EXIT_ERROR, - error: error.to_string(), + error: localize_error_message(&error.to_string()), next_retry_seconds: Some(sleep_for.as_secs()), }; eprintln!("{}", serde_json::to_string_pretty(&payload)?); + if daemon_control_stop_requested(daemon_control.as_ref()) { + logger.log_text("daemon", "停止请求已中断本轮同步,watch 循环准备退出"); + break; + } } } - thread::sleep(sleep_for); + match wait_for_daemon_wake(daemon_control.as_ref(), sleep_for) { + DaemonWake::Timeout => {} + DaemonWake::Stop => { + logger.log_text("daemon", "收到停止请求,watch 循环准备退出"); + break; + } + DaemonWake::Refresh { force } => { + pending_rpc_force = force; + logger.log_text( + "daemon", + if force { + "收到 RPC refresh --force 请求,将尽快执行强制刷新" + } else { + "收到 RPC refresh 请求,将尽快执行刷新检查" + }, + ); + } + DaemonWake::Reload => { + pending_rpc_force = true; + logger.log_text( + "daemon", + "收到 RPC reload 请求,将尽快重新执行自动发现和强制刷新", + ); + } + } } + + record_daemon_status( + options.daemon_child, + &daemon_state_dir, + &mut logger, + DaemonStatusUpdate { + state: "stopped", + last_update_status: None, + last_error: None, + next_retry_seconds: None, + pending_scheduled_force, + next_forced_refresh_at, + }, + ); + if options.daemon_child { + let _ = fs::remove_file(daemon_pid_path(&daemon_state_dir)); + let _ = fs::remove_file(daemon_socket_path(&daemon_state_dir)); + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct DaemonStatusFile { + version: u32, + pid: u32, + state: String, + resource_output_root: PathBuf, + state_dir: PathBuf, + log_path: PathBuf, + started_unix_seconds: u64, + updated_unix_seconds: u64, + last_update_status: Option, + last_error: Option, + next_retry_seconds: Option, + pending_scheduled_force: bool, + next_forced_refresh_unix_seconds: Option, + command: Vec, +} + +#[derive(Debug)] +struct DaemonStatusUpdate<'a> { + state: &'a str, + last_update_status: Option, + last_error: Option, + next_retry_seconds: Option, + pending_scheduled_force: bool, + next_forced_refresh_at: SystemTime, +} + +type DaemonControl = Arc<(Mutex, Condvar)>; + +#[derive(Debug, Default)] +struct DaemonControlState { + stop_requested: bool, + refresh_requested: bool, + force_refresh_requested: bool, + reload_requested: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DaemonWake { + Timeout, + Stop, + Refresh { force: bool }, + Reload, +} + +#[derive(Debug, Deserialize)] +struct JsonRpcRequest { + #[allow(dead_code)] + jsonrpc: Option, + id: Option, + method: String, + params: Option, +} + +#[derive(Debug, Serialize)] +struct JsonRpcResponse { + jsonrpc: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct JsonRpcError { + code: i32, + message: String, +} + +#[derive(Debug, Deserialize)] +struct JsonRpcClientResponse { + #[allow(dead_code)] + jsonrpc: String, + #[allow(dead_code)] + id: Option, + result: Option, + error: Option, +} + +#[derive(Debug, Serialize)] +struct DaemonRpcAck { + command: &'static str, + status: &'static str, + message: &'static str, + state_dir: PathBuf, + socket_path: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + force: Option, +} + +const RPC_METHOD_STATUS: &str = "bat.status"; +const RPC_METHOD_STOP: &str = "bat.stop"; +const RPC_METHOD_RELOAD: &str = "bat.reload"; +const RPC_METHOD_REFRESH: &str = "bat.refresh"; +const RPC_METHOD_LOGS: &str = "bat.logs"; + +fn record_daemon_status( + enabled: bool, + state_dir: &Path, + logger: &mut ProgressLogger, + update: DaemonStatusUpdate<'_>, +) { + if !enabled { + return; + } + + if let Err(error) = update_daemon_status( + state_dir, + update.state, + update.last_update_status, + update.last_error, + update.next_retry_seconds, + update.pending_scheduled_force, + update.next_forced_refresh_at, + ) { + logger.log_text("daemon", format!("更新后台状态失败:{error}")); + } +} + +#[derive(Debug)] +struct DaemonRpcServer { + socket_path: PathBuf, +} + +impl Drop for DaemonRpcServer { + fn drop(&mut self) { + let _ = fs::remove_file(&self.socket_path); + } +} + +fn new_daemon_control() -> DaemonControl { + Arc::new((Mutex::new(DaemonControlState::default()), Condvar::new())) +} + +fn daemon_control_stop_requested(control: Option<&DaemonControl>) -> bool { + let Some(control) = control else { + return false; + }; + let (lock, _) = &**control; + lock.lock() + .map(|state| state.stop_requested) + .unwrap_or(true) +} + +fn daemon_control_mark_stop_requested(control: &DaemonControl) { + let (lock, _) = &**control; + if let Ok(mut state) = lock.lock() { + state.stop_requested = true; + } +} + +fn daemon_control_notify_all(control: &DaemonControl) { + let (_, cvar) = &**control; + cvar.notify_all(); +} + +fn daemon_control_request_refresh(control: &DaemonControl, force: bool) { + let (lock, cvar) = &**control; + if let Ok(mut state) = lock.lock() { + state.refresh_requested = true; + state.force_refresh_requested |= force; + } + cvar.notify_all(); +} + +fn daemon_control_request_reload(control: &DaemonControl) { + let (lock, cvar) = &**control; + if let Ok(mut state) = lock.lock() { + state.reload_requested = true; + } + cvar.notify_all(); +} + +fn wait_for_daemon_wake(control: Option<&DaemonControl>, timeout: Duration) -> DaemonWake { + let Some(control) = control else { + thread::sleep(timeout); + return DaemonWake::Timeout; + }; + let (lock, cvar) = &**control; + let Ok(mut state) = lock.lock() else { + return DaemonWake::Stop; + }; + if let Some(wake) = take_daemon_wake(&mut state) { + return wake; + } + let Ok((mut state, _timeout)) = cvar.wait_timeout(state, timeout) else { + return DaemonWake::Stop; + }; + take_daemon_wake(&mut state).unwrap_or(DaemonWake::Timeout) +} + +fn take_daemon_wake(state: &mut DaemonControlState) -> Option { + if state.stop_requested { + return Some(DaemonWake::Stop); + } + if state.reload_requested { + state.reload_requested = false; + state.refresh_requested = false; + state.force_refresh_requested = false; + return Some(DaemonWake::Reload); + } + if state.refresh_requested { + state.refresh_requested = false; + let force = state.force_refresh_requested; + state.force_refresh_requested = false; + return Some(DaemonWake::Refresh { force }); + } + None +} + +#[cfg(unix)] +fn start_daemon_rpc_server( + state_dir: &Path, + control: DaemonControl, +) -> anyhow::Result { + fs::create_dir_all(state_dir)?; + let socket_path = daemon_socket_path(state_dir); + if socket_path.exists() { + match UnixStream::connect(&socket_path) { + Ok(_) => { + return Err(anyhow::anyhow!( + "后台 RPC socket 已被占用:{}", + socket_path.display() + )); + } + Err(_) => { + let _ = fs::remove_file(&socket_path); + } + } + } + + let listener = UnixListener::bind(&socket_path)?; + let server_state_dir = state_dir.to_path_buf(); + thread::Builder::new() + .name("bat-daemon-rpc".to_string()) + .spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(stream) => { + let state_dir = server_state_dir.clone(); + let control = Arc::clone(&control); + let _ = thread::Builder::new() + .name("bat-daemon-rpc-client".to_string()) + .spawn(move || handle_daemon_rpc_client(stream, state_dir, control)); + } + Err(error) => { + eprintln!("[daemon] RPC socket accept 失败:{error}"); + break; + } + } + } + })?; + + Ok(DaemonRpcServer { socket_path }) +} + +#[cfg(not(unix))] +fn start_daemon_rpc_server( + _state_dir: &Path, + _control: DaemonControl, +) -> anyhow::Result { + Err(anyhow::anyhow!("daemon RPC 目前只支持 Unix/Linux 平台")) +} + +#[cfg(unix)] +fn handle_daemon_rpc_client(mut stream: UnixStream, state_dir: PathBuf, control: DaemonControl) { + let Ok(reader_stream) = stream.try_clone() else { + return; + }; + let mut reader = BufReader::new(reader_stream); + let mut line = String::new(); + loop { + line.clear(); + let bytes_read = match reader.read_line(&mut line) { + Ok(bytes_read) => bytes_read, + Err(error) => { + eprintln!("[daemon] RPC socket 读取失败:{error}"); + return; + } + }; + if bytes_read == 0 { + return; + } + if line.trim().is_empty() { + continue; + } + + let mut notify_stop_after_response = false; + let response = match serde_json::from_str::(&line) { + Ok(request) => { + notify_stop_after_response = request.method == RPC_METHOD_STOP; + handle_daemon_rpc_request(request, &state_dir, &control) + } + Err(error) => json_rpc_error(None, -32700, format!("JSON-RPC 请求解析失败:{error}")), + }; + if let Err(error) = write_json_rpc_response(&mut stream, &response) { + if notify_stop_after_response { + daemon_control_notify_all(&control); + } + eprintln!("[daemon] RPC socket 写入失败:{error}"); + return; + } + if notify_stop_after_response { + daemon_control_notify_all(&control); + } + } +} + +fn handle_daemon_rpc_request( + request: JsonRpcRequest, + state_dir: &Path, + control: &DaemonControl, +) -> JsonRpcResponse { + let id = request.id.clone(); + let result: anyhow::Result = match request.method.as_str() { + RPC_METHOD_STATUS => { + let report = build_daemon_status_report(state_dir); + report.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)) + } + RPC_METHOD_STOP => { + daemon_control_mark_stop_requested(control); + let _ = update_daemon_state_only(state_dir, "stopping"); + serde_json::to_value(DaemonRpcAck { + command: "stop", + status: "accepted", + message: "后台停止请求已发送", + state_dir: state_dir.to_path_buf(), + socket_path: daemon_socket_path(state_dir), + force: None, + }) + .map_err(anyhow::Error::from) + } + RPC_METHOD_RELOAD => { + daemon_control_request_reload(control); + serde_json::to_value(DaemonRpcAck { + command: "reload", + status: "accepted", + message: "后台重新加载请求已发送;空闲时会立即唤醒,忙碌时会在当前轮结束后重新执行自动发现和强制刷新", + state_dir: state_dir.to_path_buf(), + socket_path: daemon_socket_path(state_dir), + force: Some(true), + }) + .map_err(anyhow::Error::from) + } + RPC_METHOD_REFRESH => { + let force = rpc_bool_param(request.params.as_ref(), "force").unwrap_or(false); + daemon_control_request_refresh(control, force); + serde_json::to_value(DaemonRpcAck { + command: "refresh", + status: "accepted", + message: if force { + "后台强制刷新请求已发送;空闲时会立即唤醒,忙碌时会在当前轮结束后执行" + } else { + "后台刷新检查请求已发送;空闲时会立即唤醒,忙碌时会在当前轮结束后执行" + }, + state_dir: state_dir.to_path_buf(), + socket_path: daemon_socket_path(state_dir), + force: Some(force), + }) + .map_err(anyhow::Error::from) + } + RPC_METHOD_LOGS => { + let tail = match rpc_tail_param(request.params.as_ref(), 200) { + Ok(tail) => tail, + Err(error) => return json_rpc_error(id, -32602, error.to_string()), + }; + let report = build_logs_report(state_dir, tail); + report.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)) + } + _ => { + return json_rpc_error(id, -32601, format!("未知后台 RPC 方法:{}", request.method)); + } + }; + + match result { + Ok(value) => json_rpc_result(id, value), + Err(error) => json_rpc_error(id, -32603, error.to_string()), + } +} + +#[cfg(unix)] +fn write_json_rpc_response( + stream: &mut UnixStream, + response: &JsonRpcResponse, +) -> anyhow::Result<()> { + serde_json::to_writer(&mut *stream, response)?; + stream.write_all(b"\n")?; + stream.flush()?; + Ok(()) +} + +fn json_rpc_result(id: Option, result: serde_json::Value) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0", + id, + result: Some(result), + error: None, + } +} + +fn json_rpc_error(id: Option, code: i32, message: String) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0", + id, + result: None, + error: Some(JsonRpcError { code, message }), + } +} + +fn rpc_bool_param(params: Option<&serde_json::Value>, key: &str) -> Option { + params + .and_then(|params| params.get(key)) + .and_then(serde_json::Value::as_bool) +} + +fn rpc_tail_param(params: Option<&serde_json::Value>, default: usize) -> anyhow::Result { + let tail = params + .and_then(|params| params.get("tail")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(default as u64); + let tail = usize::try_from(tail)?; + if tail == 0 { + return Err(anyhow::anyhow!("tail 必须大于 0")); + } + Ok(tail) +} + +#[cfg(unix)] +fn daemon_rpc_call( + state_dir: &Path, + method: &str, + params: Option, +) -> anyhow::Result { + let socket_path = daemon_socket_path(state_dir); + let mut stream = UnixStream::connect(&socket_path).map_err(|error| { + anyhow::anyhow!("无法连接后台 RPC socket {}:{error}", socket_path.display()) + })?; + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params.unwrap_or(serde_json::Value::Null), + }); + serde_json::to_writer(&mut stream, &request)?; + stream.write_all(b"\n")?; + stream.flush()?; + + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line)?; + if line.trim().is_empty() { + return Err(anyhow::anyhow!("后台 RPC socket 未返回响应")); + } + let response: JsonRpcClientResponse = serde_json::from_str(&line)?; + if let Some(error) = response.error { + return Err(anyhow::anyhow!( + "后台 RPC {} 失败:{} ({})", + method, + error.message, + error.code + )); + } + Ok(response.result.unwrap_or(serde_json::Value::Null)) +} + +#[cfg(not(unix))] +fn daemon_rpc_call( + _state_dir: &Path, + _method: &str, + _params: Option, +) -> anyhow::Result { + Err(anyhow::anyhow!("daemon RPC 目前只支持 Unix/Linux 平台")) +} + +#[cfg(unix)] +fn daemon_rpc_available(state_dir: &Path) -> bool { + UnixStream::connect(daemon_socket_path(state_dir)).is_ok() +} + +#[cfg(not(unix))] +fn daemon_rpc_available(_state_dir: &Path) -> bool { + false +} + +#[derive(Debug, Serialize)] +struct DaemonStartReport { + status: &'static str, + message: &'static str, + pid: u32, + resource_output_root: PathBuf, + state_dir: PathBuf, + pid_path: PathBuf, + status_path: PathBuf, + log_path: PathBuf, + socket_path: PathBuf, +} + +#[derive(Debug, Serialize)] +struct DaemonStatusReport { + status: &'static str, + message: &'static str, + running: bool, + pid: Option, + resource_output_root: Option, + state_dir: PathBuf, + pid_path: PathBuf, + status_path: PathBuf, + socket_path: PathBuf, + rpc_available: bool, + log_path: Option, + started_unix_seconds: Option, + updated_unix_seconds: Option, + daemon_state: Option, + last_update_status: Option, + last_error: Option, + next_retry_seconds: Option, + pending_scheduled_force: Option, + next_forced_refresh_unix_seconds: Option, + command: Option>, +} + +#[derive(Debug, Serialize)] +struct DaemonStopReport { + status: &'static str, + message: &'static str, + stopped: bool, + pid: Option, + state_dir: PathBuf, + pid_path: PathBuf, + socket_path: PathBuf, +} + +fn run_daemon_start(options: CliOptions) -> anyhow::Result<()> { + if options.config.dry_run { + return Err(anyhow::anyhow!("daemon 后台模式不能和 --dry-run 同时使用")); + } + if options.watch { + return Err(anyhow::anyhow!( + "--daemon 会自动启动 watch 模式,不要同时传 --watch" + )); + } + + let report = start_daemon_with_options(&options)?; + print_report(options.output_format, &report)?; + Ok(()) +} + +fn start_daemon_with_options(options: &CliOptions) -> anyhow::Result { + let resource_output_root = options.config.output_root.clone(); + let state_dir = options.state_dir.clone(); + let args = daemon_child_args(options); + start_daemon_with_args(state_dir, resource_output_root, args, "后台同步已启动") +} + +fn start_daemon_with_args( + state_dir: PathBuf, + resource_output_root: PathBuf, + args: Vec, + message: &'static str, +) -> anyhow::Result { + fs::create_dir_all(&state_dir)?; + let pid_path = daemon_pid_path(&state_dir); + let status_path = daemon_status_path(&state_dir); + let log_path = daemon_log_path(&state_dir); + let socket_path = daemon_socket_path(&state_dir); + + if let Some(existing_pid) = read_pid_file(&pid_path)? { + if process_exists(existing_pid) { + return Err(anyhow::anyhow!( + "官方同步后台进程已经在运行,pid={existing_pid}" + )); + } + let _ = fs::remove_file(&pid_path); + let _ = fs::remove_file(&socket_path); + } + + let executable = env::current_exe()?; + let log = OpenOptions::new() + .create(true) + .append(true) + .open(&log_path)?; + let log_for_stdout = log.try_clone()?; + let mut command = Command::new(&executable); + command + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::from(log_for_stdout)) + .stderr(Stdio::from(log)); + configure_daemon_command(&mut command); + let child = command.spawn()?; + let pid = child.id(); + + fs::write(&pid_path, pid.to_string())?; + let mut command = Vec::with_capacity(args.len() + 1); + command.push(executable.to_string_lossy().to_string()); + command.extend(args); + let status = DaemonStatusFile { + version: DAEMON_STATUS_VERSION, + pid, + state: "started".to_string(), + resource_output_root: resource_output_root.clone(), + state_dir: state_dir.clone(), + log_path: log_path.clone(), + started_unix_seconds: unix_seconds_now(), + updated_unix_seconds: unix_seconds_now(), + last_update_status: None, + last_error: None, + next_retry_seconds: None, + pending_scheduled_force: false, + next_forced_refresh_unix_seconds: None, + command, + }; + write_daemon_status_file(&status_path, &status)?; + + Ok(DaemonStartReport { + status: "started", + message, + pid, + resource_output_root, + state_dir, + pid_path, + status_path, + log_path, + socket_path, + }) +} + +fn print_daemon_status(state_dir: &Path, output_format: OutputFormat) -> anyhow::Result<()> { + if let Ok(report) = daemon_rpc_call(state_dir, RPC_METHOD_STATUS, None) { + print_json_value(output_format, &report)?; + } else { + let report = build_daemon_status_report(state_dir)?; + print_report(output_format, &report)?; + } + Ok(()) +} + +fn stop_daemon(state_dir: &Path, output_format: OutputFormat) -> anyhow::Result<()> { + if daemon_rpc_available(state_dir) { + let pid = read_pid_file(&daemon_pid_path(state_dir))?.or_else(|| { + read_daemon_status_file(&daemon_status_path(state_dir)) + .ok() + .flatten() + .map(|status| status.pid) + }); + if daemon_rpc_call(state_dir, RPC_METHOD_STOP, None).is_ok() { + let forced_signal = if let Some(pid) = pid { + wait_for_rpc_stop_or_terminate(pid, Duration::from_secs(10))? + } else { + false + }; + let pid_path = daemon_pid_path(state_dir); + let socket_path = daemon_socket_path(state_dir); + let _ = fs::remove_file(&pid_path); + let _ = fs::remove_file(&socket_path); + let report = DaemonStopReport { + status: if forced_signal { + "stopped_after_signal" + } else { + "stopped" + }, + message: if forced_signal { + "后台进程已接收 RPC 停止请求,但未在优雅窗口内退出,已发送 SIGTERM 后停止" + } else { + "后台进程已通过 RPC 停止" + }, + stopped: true, + pid, + state_dir: state_dir.to_path_buf(), + pid_path, + socket_path, + }; + print_report(output_format, &report)?; + return Ok(()); + } + } + + let pid_path = daemon_pid_path(state_dir); + let Some(_pid) = read_pid_file(&pid_path)? else { + let report = stop_daemon_inner(state_dir)?; + print_report(output_format, &report)?; + return Ok(()); + }; + + let report = stop_daemon_inner(state_dir)?; + print_report(output_format, &report)?; + Ok(()) +} + +fn stop_daemon_inner(state_dir: &Path) -> anyhow::Result { + let pid_path = daemon_pid_path(state_dir); + let Some(pid) = read_pid_file(&pid_path)? else { + return Ok(DaemonStopReport { + status: "not_running", + message: "后台进程当前未运行", + stopped: false, + pid: None, + state_dir: state_dir.to_path_buf(), + pid_path, + socket_path: daemon_socket_path(state_dir), + }); + }; + + let running = process_exists(pid); + if running { + terminate_process(pid)?; + if !wait_for_process_exit(pid, Duration::from_secs(5)) { + return Err(anyhow::anyhow!("后台进程 pid={pid} 在 5 秒内未停止")); + } + } + let _ = fs::remove_file(&pid_path); + let _ = fs::remove_file(daemon_socket_path(state_dir)); + + Ok(DaemonStopReport { + status: if running { + "stopped" + } else { + "stale_pid_removed" + }, + message: if running { + "后台进程已停止" + } else { + "已清理失效的后台 PID 文件" + }, + stopped: running, + pid: Some(pid), + state_dir: state_dir.to_path_buf(), + pid_path, + socket_path: daemon_socket_path(state_dir), + }) +} + +fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result { + let pid_path = daemon_pid_path(state_dir); + let status_path = daemon_status_path(state_dir); + let socket_path = daemon_socket_path(state_dir); + let status_file = read_daemon_status_file(&status_path)?; + let pid = read_pid_file(&pid_path)?.or_else(|| status_file.as_ref().map(|status| status.pid)); + let running = pid.map(process_exists).unwrap_or(false); + let rpc_available = daemon_rpc_available(state_dir); + + Ok(DaemonStatusReport { + status: if running { "running" } else { "stopped" }, + message: if running { + "后台进程正在运行" + } else { + "后台进程当前未运行" + }, + running, + pid, + resource_output_root: status_file + .as_ref() + .map(|status| status.resource_output_root.clone()), + state_dir: state_dir.to_path_buf(), + pid_path, + status_path, + socket_path, + rpc_available, + log_path: status_file + .as_ref() + .map(|status| status.log_path.clone()) + .or_else(|| Some(daemon_log_path(state_dir)).filter(|path| path.exists())), + started_unix_seconds: status_file + .as_ref() + .map(|status| status.started_unix_seconds), + updated_unix_seconds: status_file + .as_ref() + .map(|status| status.updated_unix_seconds), + daemon_state: status_file.as_ref().map(|status| status.state.clone()), + last_update_status: status_file + .as_ref() + .and_then(|status| status.last_update_status.clone()), + last_error: status_file + .as_ref() + .and_then(|status| status.last_error.clone()), + next_retry_seconds: status_file + .as_ref() + .and_then(|status| status.next_retry_seconds), + pending_scheduled_force: status_file + .as_ref() + .map(|status| status.pending_scheduled_force), + next_forced_refresh_unix_seconds: status_file + .as_ref() + .and_then(|status| status.next_forced_refresh_unix_seconds), + command: status_file.map(|status| status.command), + }) +} + +#[derive(Debug, Serialize)] +struct DaemonControlReport { + command: &'static str, + status: &'static str, + message: &'static str, + strategy: &'static str, + previous_pid: Option, + pid: u32, + state_dir: PathBuf, + resource_output_root: PathBuf, + log_path: PathBuf, + socket_path: PathBuf, +} + +fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyhow::Result<()> { + let has_explicit_options = options.sync_option_explicit + || options.output_explicit + || tools_are_non_default(&options.config); + if command_name == "reload" && !has_explicit_options && daemon_rpc_available(&options.state_dir) + { + let report = daemon_rpc_call(&options.state_dir, RPC_METHOD_RELOAD, None)?; + print_json_value(options.output_format, &report)?; + return Ok(()); + } + + let status_file = read_daemon_status_file(&daemon_status_path(&options.state_dir))?; + let status_report = build_daemon_status_report(&options.state_dir)?; + let previous_pid = status_report.pid.filter(|pid| process_exists(*pid)); + + if previous_pid.is_some() { + if daemon_rpc_available(&options.state_dir) + && daemon_rpc_call(&options.state_dir, RPC_METHOD_STOP, None).is_ok() + { + if let Some(pid) = previous_pid { + let _ = wait_for_rpc_stop_or_terminate(pid, Duration::from_secs(10))?; + } + let _ = fs::remove_file(daemon_pid_path(&options.state_dir)); + let _ = fs::remove_file(daemon_socket_path(&options.state_dir)); + } else { + let _ = stop_daemon_inner(&options.state_dir)?; + } + } else if read_pid_file(&daemon_pid_path(&options.state_dir))?.is_some() { + let _ = stop_daemon_inner(&options.state_dir)?; + } + + let (state_dir, resource_output_root, args, strategy) = if has_explicit_options { + let mut start_options = options.clone(); + start_options.daemon = true; + start_options.watch = false; + ( + options.state_dir.clone(), + options.config.output_root.clone(), + daemon_child_args(&start_options), + "start_with_explicit_options", + ) + } else { + let status_file = status_file.ok_or_else(|| { + anyhow::anyhow!( + "没有可复用的后台配置;请先执行 bat --auto-discover --daemon,或为 {command_name} 显式传入同步参数" + ) + })?; + let mut command = status_file.command.into_iter(); + let _executable = command.next().ok_or_else(|| { + anyhow::anyhow!("后台状态文件中的 command 为空,无法执行 {command_name}") + })?; + let args = command.collect::>(); + if args.is_empty() { + return Err(anyhow::anyhow!( + "后台状态文件中的 command 参数为空,无法执行 {command_name}" + )); + } + ( + options.state_dir.clone(), + status_file.resource_output_root, + args, + "restart_with_existing_command", + ) + }; + + let start = start_daemon_with_args( + state_dir.clone(), + resource_output_root.clone(), + args, + if command_name == "reload" { + "后台配置已重新加载" + } else { + "后台进程已重启" + }, + )?; + let report = DaemonControlReport { + command: command_name, + status: if command_name == "reload" { + "reloaded" + } else { + "restarted" + }, + message: if command_name == "reload" { + "已使用原后台参数重启进程完成配置重新加载" + } else { + "已停止旧后台进程并启动新后台进程" + }, + strategy, + previous_pid, + pid: start.pid, + state_dir, + resource_output_root, + log_path: start.log_path, + socket_path: start.socket_path, + }; + print_report(options.output_format, &report)?; + Ok(()) +} + +#[derive(Debug, Serialize)] +struct CommandReport { + command: &'static str, + status: &'static str, + message: &'static str, + data: T, +} + +fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow::Result<()> { + if refresh_should_use_daemon_rpc(options, command_name) + && daemon_rpc_available(&options.state_dir) + { + let report = daemon_rpc_call( + &options.state_dir, + RPC_METHOD_REFRESH, + Some(serde_json::json!({ "force": options.config.force })), + )?; + print_json_value(options.output_format, &report)?; + return Ok(()); + } + + let mut config = options.config.clone(); + if command_name == "repair" { + config.repair = true; + config.force = false; + } + let mut logger = ProgressLogger::new(options.progress); + let report = + OfficialUpdateService::new().run_with_progress(&config, |event| logger.log(event))?; + let command_report = CommandReport { + command: command_name, + status: "completed", + message: if command_name == "repair" { + "资源修复命令已执行" + } else if report.update_status == OfficialUpdateStatus::UpToDate { + "资源已是最新,无需更新" + } else { + "资源刷新命令已执行" + }, + data: report, + }; + print_report(options.output_format, &command_report)?; + Ok(()) +} + +fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bool { + if command_name != "refresh" { + return false; + } + let defaults = OfficialUpdateConfig::default(); + options.command == CliCommand::Refresh + && !options.watch + && !options.daemon + && !options.daemon_child + && !options.output_explicit + && options.config.server_info_source.is_none() + && options.config.connection_group.is_none() + && options.config.app_version.is_none() + && options.config.launcher_version == defaults.launcher_version + && options.config.platforms.is_none() + && options.config.output_root == defaults.output_root + && options.config.snapshot_path.is_none() + && options.config.curl_command == defaults.curl_command + && options.config.unzip_command == defaults.unzip_command + && !options.config.dry_run + && !options.config.plan + && options.config.audit_local == defaults.audit_local + && options.config.repair == defaults.repair +} + +fn print_report(format: OutputFormat, report: &T) -> anyhow::Result<()> +where + T: Serialize + HumanReport, +{ + match format { + OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?), + OutputFormat::Human => report.print_human()?, + } + Ok(()) +} + +fn print_json_value(format: OutputFormat, value: &serde_json::Value) -> anyhow::Result<()> { + match format { + OutputFormat::Json => println!("{}", serde_json::to_string_pretty(value)?), + OutputFormat::Human => print_human_json_value(value)?, + } + Ok(()) +} + +trait HumanReport { + fn print_human(&self) -> anyhow::Result<()>; +} + +fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> { + if value.get("running").is_some() && value.get("state_dir").is_some() { + print_title("后台状态"); + print_json_field(value, "status", "状态"); + print_json_field(value, "message", "消息"); + print_json_field(value, "running", "运行中"); + print_json_field(value, "pid", "PID"); + print_json_field(value, "daemon_state", "后台状态"); + print_json_field(value, "rpc_available", "RPC 可用"); + print_json_field(value, "last_update_status", "上次同步"); + print_json_field(value, "last_error", "上次错误"); + print_json_field(value, "next_retry_seconds", "下次重试秒数"); + print_json_field(value, "resource_output_root", "资源目录"); + print_json_field(value, "state_dir", "状态目录"); + print_json_field(value, "socket_path", "socket"); + print_json_field(value, "log_path", "日志"); + return Ok(()); + } + if value.get("command").and_then(serde_json::Value::as_str) == Some("logs") { + print_title("后台日志"); + print_json_field(value, "status", "状态"); + print_json_field(value, "message", "消息"); + print_json_field(value, "log_path", "日志"); + print_json_field(value, "bytes", "字节"); + print_json_field(value, "total_lines", "总行数"); + print_json_field(value, "returned_lines", "返回行数"); + if let Some(content) = value.get("content").and_then(serde_json::Value::as_str) { + if !content.is_empty() { + println!(); + println!("{content}"); + } + } + return Ok(()); + } + if value.get("command").is_some() && value.get("status").is_some() { + print_title("后台命令"); + print_json_field(value, "command", "命令"); + print_json_field(value, "status", "状态"); + print_json_field(value, "message", "消息"); + print_json_field(value, "force", "force"); + print_json_field(value, "state_dir", "状态目录"); + print_json_field(value, "socket_path", "socket"); + return Ok(()); + } + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} + +fn print_json_field(value: &serde_json::Value, key: &str, label: &str) { + let Some(value) = value.get(key) else { + return; + }; + if value.is_null() { + return; + } + if let Some(value) = value.as_str() { + print_field(label, value); + } else { + print_field(label, value); + } +} + +fn print_title(title: &str) { + println!("{title}"); +} + +fn print_field(label: &str, value: impl std::fmt::Display) { + println!(" {label:<18} {value}"); +} + +fn print_optional_field(label: &str, value: Option) +where + T: std::fmt::Display, +{ + if let Some(value) = value { + print_field(label, value); + } +} + +fn print_path_field(label: &str, value: &Path) { + print_field(label, value.display()); +} + +fn print_optional_path_field(label: &str, value: Option<&PathBuf>) { + if let Some(value) = value { + print_path_field(label, value); + } +} + +fn print_list(label: &str, values: &[String], limit: usize) { + if values.is_empty() { + return; + } + println!(" {label}:"); + for value in values.iter().take(limit) { + println!(" - {value}"); + } + if values.len() > limit { + println!(" ... 还有 {} 项", values.len() - limit); + } +} + +fn format_bool(value: bool) -> &'static str { + if value { + "yes" + } else { + "no" + } +} + +fn format_bytes(value: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = 1024.0 * 1024.0; + const GIB: f64 = 1024.0 * 1024.0 * 1024.0; + let value_f = value as f64; + if value_f >= GIB { + format!("{value_f:.2} GiB", value_f = value_f / GIB) + } else if value_f >= MIB { + format!("{value_f:.2} MiB", value_f = value_f / MIB) + } else if value_f >= KIB { + format!("{value_f:.2} KiB", value_f = value_f / KIB) + } else { + format!("{value} B") + } +} + +fn platform_label(platform: PatchPlatform) -> &'static str { + match platform { + PatchPlatform::Windows => "Windows", + PatchPlatform::Android => "Android", + } +} + +impl HumanReport for OfficialUpdateReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title("官方资源同步"); + print_field("状态", self.update_status.as_str()); + print_field("应用版本", &self.app_version); + print_optional_field("Bundle 版本", self.bundle_version.as_deref()); + print_field("连接组", &self.connection_group); + print_field( + "平台", + self.platforms + .iter() + .map(|platform| platform_label(*platform)) + .collect::>() + .join(", "), + ); + print_field("需要下载", format_bool(self.should_download)); + print_field("首次同步", format_bool(self.is_initial)); + print_field("强制刷新", format_bool(self.force)); + print_field("本地审计", format_bool(self.audit_local)); + print_field("自动修复", format_bool(self.repair)); + print_field("dry-run", format_bool(self.dry_run)); + print_path_field("snapshot", &self.snapshot_path); + print_path_field("manifest", &self.download_manifest); + print_optional_path_field("写入 snapshot", self.snapshot_written.as_ref()); + print_optional_path_field("bootstrap cache", self.bootstrap_cache_path.as_ref()); + print_optional_field("bootstrap 命中", self.bootstrap_cache_hit.map(format_bool)); + print_optional_field("计划 URL 数", self.download_url_count); + print_optional_field("资源数", self.resource_count); + print_field("已下载", self.downloaded_count); + print_field("已续传", self.resumed_count); + print_field("已跳过", self.skipped_count); + print_field("传输量", format_bytes(self.transferred_bytes)); + print_field("最终大小", format_bytes(self.final_bytes)); + print_field("本地校验通过", self.local_manifest_verified_count); + print_field("需修复", self.local_manifest_repair_needed_count); + print_field("官方 hash 校验", self.official_seed_hash_verified_count); + print_field("catalog marker", self.addressables_marker_checked_count); + print_list("变更 endpoint", &self.changed_endpoint_urls, 8); + print_list("计划 URL", &self.download_urls, 8); + Ok(()) + } +} + +impl HumanReport for CommandReport +where + T: Serialize + HumanReport, +{ + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("命令", self.command); + print_field("状态", self.status); + self.data.print_human() + } +} + +impl HumanReport for DaemonStartReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("状态", self.status); + print_field("PID", self.pid); + print_path_field("资源目录", &self.resource_output_root); + print_path_field("状态目录", &self.state_dir); + print_path_field("socket", &self.socket_path); + print_path_field("日志", &self.log_path); + Ok(()) + } +} + +impl HumanReport for DaemonStatusReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title("后台状态"); + print_field("状态", self.status); + print_field("消息", self.message); + print_field("运行中", format_bool(self.running)); + print_optional_field("PID", self.pid); + print_optional_field("后台状态", self.daemon_state.as_deref()); + print_field("RPC 可用", format_bool(self.rpc_available)); + print_optional_field("上次同步", self.last_update_status.as_deref()); + print_optional_field("上次错误", self.last_error.as_deref()); + print_optional_field("下次重试秒数", self.next_retry_seconds); + print_optional_path_field("资源目录", self.resource_output_root.as_ref()); + print_path_field("状态目录", &self.state_dir); + print_path_field("socket", &self.socket_path); + print_optional_path_field("日志", self.log_path.as_ref()); + Ok(()) + } +} + +impl HumanReport for DaemonStopReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("状态", self.status); + print_field("已停止", format_bool(self.stopped)); + print_optional_field("PID", self.pid); + print_path_field("状态目录", &self.state_dir); + print_path_field("socket", &self.socket_path); + Ok(()) + } +} + +impl HumanReport for DaemonControlReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("命令", self.command); + print_field("状态", self.status); + print_field("策略", self.strategy); + print_optional_field("旧 PID", self.previous_pid); + print_field("PID", self.pid); + print_path_field("资源目录", &self.resource_output_root); + print_path_field("状态目录", &self.state_dir); + print_path_field("socket", &self.socket_path); + print_path_field("日志", &self.log_path); + Ok(()) + } +} + +impl HumanReport for VerifyCommandReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("命令", self.command); + print_field("状态", self.status); + print_field("健康", format_bool(self.healthy)); + print_field("远端状态", &self.remote_update_status); + print_optional_field("计划 URL 数", self.planned_url_count); + print_field("计划异常数", self.expected_plan_failure_count); + print_field("本地 manifest 项", self.local_manifest_entry_count); + print_field("本地校验通过", self.local_manifest_verified_count); + print_field("本地失败数", self.local_manifest_failure_count); + print_field("官方 hash 对", self.official_hash_pair_count); + print_field("官方 hash 通过", self.official_hash_verified_count); + if !self.official_hash_errors.is_empty() { + print_list("官方 hash 错误", &self.official_hash_errors, 8); + } + if !self.failures.is_empty() { + println!(" 失败项:"); + for item in self.failures.iter().take(12) { + println!(" - {} -> {}", item.status, item.destination.display()); + } + if self.failures.len() > 12 { + println!(" ... 还有 {} 项", self.failures.len() - 12); + } + } + Ok(()) + } +} + +impl HumanReport for LogsReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("状态", self.status); + print_path_field("日志", &self.log_path); + print_field("存在", format_bool(self.exists)); + print_field("为空", format_bool(self.empty)); + print_field("字节", self.bytes); + print_field("总行数", self.total_lines); + print_field("返回行数", self.returned_lines); + if !self.content.is_empty() { + println!(); + println!("{}", self.content); + } + Ok(()) + } +} + +impl HumanReport for DoctorReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("命令", self.command); + print_field("状态", self.status); + print_field("健康", format_bool(self.healthy)); + println!(" 检查:"); + for check in &self.checks { + println!( + " [{}] {} - {}", + if check.ok { "OK" } else { "FAIL" }, + check.name, + check.message + ); + } + Ok(()) + } +} + +impl HumanReport for CleanStableReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("命令", self.command); + print_field("状态", self.status); + print_path_field("资源目录", &self.output_root); + print_path_field("状态目录", &self.state_dir); + if !self.removed_paths.is_empty() { + println!(" 已清理:"); + for path in &self.removed_paths { + println!(" - {}", path.display()); + } + } + if !self.skipped_paths.is_empty() { + println!(" 已跳过:"); + for path in &self.skipped_paths { + println!(" - {}", path.display()); + } + } + Ok(()) + } +} + +impl HumanReport for DaemonRpcAck { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("命令", self.command); + print_field("状态", self.status); + print_optional_field("force", self.force.map(format_bool)); + print_path_field("状态目录", &self.state_dir); + print_path_field("socket", &self.socket_path); + Ok(()) + } +} + +#[derive(Debug, Serialize)] +struct VerificationItemReport { + url: String, + destination: PathBuf, + status: String, + expected_bytes: Option, + actual_bytes: Option, + expected_blake3: Option, + actual_blake3: Option, +} + +#[derive(Debug, Serialize)] +struct VerifyCommandReport { + command: &'static str, + status: &'static str, + message: &'static str, + healthy: bool, + remote_update_status: String, + planned_url_count: Option, + expected_plan_failure_count: usize, + local_manifest_entry_count: usize, + local_manifest_verified_count: usize, + local_manifest_failure_count: usize, + official_hash_pair_count: usize, + official_hash_verified_count: usize, + official_hash_errors: Vec, + failures: Vec, +} + +fn run_verify_command(options: &CliOptions) -> anyhow::Result { + let mut config = options.config.clone(); + config.dry_run = true; + config.plan = true; + config.audit_local = true; + config.repair = false; + config.force = false; + + let mut logger = ProgressLogger::new(options.progress); + let update_report = + OfficialUpdateService::new().run_with_progress(&config, |event| logger.log(event))?; + let verification = bat_infrastructure::OfficialResourcePullService::with_curl_command( + &config.output_root, + &config.curl_command, + ) + .verify_local_download_manifest() + .map_err(anyhow::Error::msg)?; + let failures = verification + .items + .iter() + .filter(|item| !item.status.is_verified()) + .map(|item| VerificationItemReport { + url: item.url.clone(), + destination: item.destination.clone(), + status: item.status.as_str().to_string(), + expected_bytes: item.expected_bytes, + actual_bytes: item.actual_bytes, + expected_blake3: item.expected_blake3.clone(), + actual_blake3: item.actual_blake3.clone(), + }) + .collect::>(); + let healthy = update_report.update_status == OfficialUpdateStatus::UpToDate + && update_report.local_manifest_repair_needed_count == 0 + && verification.is_clean(); + let report = VerifyCommandReport { + command: "verify", + status: if healthy { "verified" } else { "failed" }, + message: if healthy { + "所有当前计划资源和本地 manifest 均通过验证" + } else { + "发现资源或官方 seed hash 校验异常" + }, + healthy, + remote_update_status: update_report.update_status.as_str().to_string(), + planned_url_count: update_report.download_url_count, + expected_plan_failure_count: update_report.local_manifest_repair_needed_count, + local_manifest_entry_count: verification.items.len(), + local_manifest_verified_count: verification.verified_count(), + local_manifest_failure_count: verification.failure_count(), + official_hash_pair_count: verification.official_hash_pair_count, + official_hash_verified_count: verification.official_hash_verified_count, + official_hash_errors: verification.official_hash_errors, + failures, + }; + print_report(options.output_format, &report)?; + Ok(healthy) +} + +#[derive(Debug, Serialize)] +struct LogsReport { + command: &'static str, + status: &'static str, + message: &'static str, + log_path: PathBuf, + exists: bool, + empty: bool, + bytes: usize, + total_lines: usize, + returned_lines: usize, + content: String, +} + +fn run_logs_command(options: &CliOptions) -> anyhow::Result<()> { + if daemon_rpc_available(&options.state_dir) { + let report = daemon_rpc_call( + &options.state_dir, + RPC_METHOD_LOGS, + Some(serde_json::json!({ "tail": options.tail_lines })), + )?; + print_json_value(options.output_format, &report)?; + return Ok(()); + } + + let report = build_logs_report(&options.state_dir, options.tail_lines)?; + print_report(options.output_format, &report)?; + Ok(()) +} + +fn build_logs_report(state_dir: &Path, tail_lines: usize) -> anyhow::Result { + let status_file = read_daemon_status_file(&daemon_status_path(state_dir))?; + let log_path = status_file + .as_ref() + .map(|status| status.log_path.clone()) + .unwrap_or_else(|| daemon_log_path(state_dir)); + let mut exists = true; + let bytes = match fs::read(&log_path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(error) => return Err(error.into()), + }; + if bytes.is_empty() && !log_path.exists() { + exists = false; + } + let raw = String::from_utf8_lossy(&bytes); + let lines = raw.lines().collect::>(); + let start = lines.len().saturating_sub(tail_lines); + let content = lines[start..].join("\n"); + Ok(LogsReport { + command: "logs", + status: if bytes.is_empty() { "empty" } else { "ok" }, + message: if bytes.is_empty() { + "后台日志为空或尚未创建" + } else { + "已读取后台日志" + }, + log_path, + exists, + empty: bytes.is_empty(), + bytes: bytes.len(), + total_lines: lines.len(), + returned_lines: lines.len().saturating_sub(start), + content, + }) +} + +#[derive(Debug, Serialize)] +struct DoctorCheck { + name: &'static str, + ok: bool, + message: String, +} + +#[derive(Debug, Serialize)] +struct DoctorReport { + command: &'static str, + status: &'static str, + message: &'static str, + healthy: bool, + checks: Vec, +} + +fn run_doctor_command(options: &CliOptions) -> anyhow::Result { + let mut checks = Vec::new(); + checks.push(path_check( + "state_dir", + &options.state_dir, + "后台状态目录可用", + )); + checks.push(path_check( + "output_root", + &options.config.output_root, + "资源输出目录可用", + )); + checks.push(command_check("curl", &options.config.curl_command)); + checks.push(command_check("unzip", &options.config.unzip_command)); + + let pid_path = daemon_pid_path(&options.state_dir); + let daemon_running = read_pid_file(&pid_path) + .ok() + .flatten() + .is_some_and(process_exists); + match read_pid_file(&pid_path) { + Ok(Some(pid)) if process_exists(pid) => checks.push(DoctorCheck { + name: "daemon", + ok: true, + message: format!("后台进程正在运行,pid={pid}"), + }), + Ok(Some(pid)) => checks.push(DoctorCheck { + name: "daemon", + ok: true, + message: format!("发现失效 PID 文件,pid={pid};可执行 clean-stable 清理"), + }), + Ok(None) => checks.push(DoctorCheck { + name: "daemon", + ok: true, + message: "后台进程当前未运行".to_string(), + }), + Err(error) => checks.push(DoctorCheck { + name: "daemon", + ok: false, + message: format!("读取后台 PID 失败:{error}"), + }), + } + + let socket_path = daemon_socket_path(&options.state_dir); + let socket_exists = socket_path.exists(); + let socket_available = daemon_rpc_available(&options.state_dir); + checks.push(DoctorCheck { + name: "daemon_rpc", + ok: if daemon_running { + socket_available + } else { + true + }, + message: if socket_available { + format!("后台 RPC socket 可连接:{}", socket_path.display()) + } else if daemon_running { + format!( + "后台进程正在运行,但 RPC socket 不可连接:{}", + socket_path.display() + ) + } else if socket_exists { + format!( + "发现失效后台 RPC socket:{};可执行 clean-stable 清理", + socket_path.display() + ) + } else { + "后台 RPC socket 尚未创建".to_string() + }, + }); + + let lock_path = options.config.lock_path(); + if lock_path.exists() { + let lock_owner = fs::read_to_string(&lock_path) + .ok() + .and_then(|contents| contents.trim().parse::().ok()); + let active = lock_owner.map(process_exists).unwrap_or(true); + checks.push(DoctorCheck { + name: "resource_lock", + ok: !active, + message: if active { + format!("资源目录锁正在使用:{}", lock_path.display()) + } else { + format!("发现失效资源锁:{}", lock_path.display()) + }, + }); + } else { + checks.push(DoctorCheck { + name: "resource_lock", + ok: true, + message: "资源目录没有活动锁".to_string(), + }); + } + + let status_path = daemon_status_path(&options.state_dir); + checks.push(DoctorCheck { + name: "daemon_status", + ok: match read_daemon_status_file(&status_path) { + Ok(Some(_)) | Ok(None) => true, + Err(_) => false, + }, + message: if status_path.exists() { + format!("后台状态文件可解析:{}", status_path.display()) + } else { + "后台状态文件尚未创建".to_string() + }, + }); + + let healthy = checks.iter().all(|check| check.ok); + let report = DoctorReport { + command: "doctor", + status: if healthy { "ok" } else { "issues_found" }, + message: if healthy { + "运行时诊断通过" + } else { + "运行时诊断发现问题" + }, + healthy, + checks, + }; + print_report(options.output_format, &report)?; + Ok(healthy) +} + +fn path_check(name: &'static str, path: &Path, ready_message: &str) -> DoctorCheck { + let (ok, message) = if path.is_dir() { + (true, format!("{ready_message}:{}", path.display())) + } else if path.exists() { + (false, format!("路径存在但不是目录:{}", path.display())) + } else { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + ( + parent.is_dir(), + if parent.is_dir() { + format!( + "目录尚未创建,但父目录可用,将在首次运行时创建:{}", + path.display() + ) + } else { + format!("目录不存在且父目录不可用:{}", path.display()) + }, + ) + }; + DoctorCheck { name, ok, message } +} + +fn command_check(name: &'static str, command: &Path) -> DoctorCheck { + let result = Command::new(command).arg("--version").output(); + DoctorCheck { + name, + ok: result.is_ok(), + message: match result { + Ok(output) => format!( + "命令可执行:{}(退出码={:?})", + command.display(), + output.status.code() + ), + Err(error) => format!("命令不可执行 {}:{error}", command.display()), + }, + } +} + +#[derive(Debug, Serialize)] +struct CleanStableReport { + command: &'static str, + status: &'static str, + message: &'static str, + output_root: PathBuf, + state_dir: PathBuf, + removed_paths: Vec, + skipped_paths: Vec, +} + +fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> { + let status = build_daemon_status_report(&options.state_dir)?; + if status.running || status.rpc_available { + return Err(anyhow::anyhow!( + "后台进程正在运行,不能执行 clean-stable;请先执行 stop" + )); + } + + let mut removed_paths = Vec::new(); + let mut skipped_paths = Vec::new(); + for root in [&options.config.output_root, &options.state_dir] { + collect_transient_files(root, &mut removed_paths)?; + } + + let pid_path = daemon_pid_path(&options.state_dir); + if let Some(pid) = read_pid_file(&pid_path)? { + if !process_exists(pid) { + fs::remove_file(&pid_path)?; + removed_paths.push(pid_path); + } + } + + let socket_path = daemon_socket_path(&options.state_dir); + if socket_path.exists() { + if daemon_rpc_available(&options.state_dir) { + skipped_paths.push(socket_path); + } else { + fs::remove_file(&socket_path)?; + removed_paths.push(socket_path); + } + } + + let lock_path = options.config.lock_path(); + if lock_path.exists() { + let owner = fs::read_to_string(&lock_path) + .ok() + .and_then(|contents| contents.trim().parse::().ok()); + if owner.is_some_and(|pid| !process_exists(pid)) { + fs::remove_file(&lock_path)?; + removed_paths.push(lock_path); + } else { + skipped_paths.push(lock_path); + } + } + + let report = CleanStableReport { + command: "clean-stable", + status: if skipped_paths.is_empty() { + "cleaned" + } else { + "cleaned_with_skips" + }, + message: "已清理断点临时文件、临时状态文件和失效锁/PID;不会删除正式资源", + output_root: options.config.output_root.clone(), + state_dir: options.state_dir.clone(), + removed_paths, + skipped_paths, + }; + print_report(options.output_format, &report)?; + Ok(()) +} + +fn collect_transient_files(root: &Path, removed_paths: &mut Vec) -> anyhow::Result<()> { + if !root.exists() { + return Ok(()); + } + let metadata = fs::symlink_metadata(root)?; + if !metadata.is_dir() { + return Ok(()); + } + + for entry in fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path)?; + if metadata.is_dir() { + collect_transient_files(&path, removed_paths)?; + continue; + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + if file_name.ends_with(".part") || file_name.ends_with(".tmp") { + fs::remove_file(&path)?; + removed_paths.push(path); + } + } + Ok(()) +} + +fn read_daemon_status_file(path: &Path) -> anyhow::Result> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + Ok(Some(serde_json::from_slice(&bytes)?)) +} + +fn write_daemon_status_file(path: &Path, status: &DaemonStatusFile) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let temporary = path.with_extension("json.tmp"); + fs::write(&temporary, serde_json::to_vec_pretty(status)?)?; + fs::rename(&temporary, path)?; + Ok(()) +} + +fn update_daemon_status( + state_dir: &Path, + state: &str, + last_update_status: Option, + last_error: Option, + next_retry_seconds: Option, + pending_scheduled_force: bool, + next_forced_refresh_at: SystemTime, +) -> anyhow::Result<()> { + let status_path = daemon_status_path(state_dir); + let mut status = read_daemon_status_file(&status_path)?.unwrap_or_else(|| DaemonStatusFile { + version: DAEMON_STATUS_VERSION, + pid: std::process::id(), + state: "started".to_string(), + resource_output_root: OfficialUpdateConfig::default().output_root, + state_dir: state_dir.to_path_buf(), + log_path: daemon_log_path(state_dir), + started_unix_seconds: unix_seconds_now(), + updated_unix_seconds: unix_seconds_now(), + last_update_status: None, + last_error: None, + next_retry_seconds: None, + pending_scheduled_force: false, + next_forced_refresh_unix_seconds: None, + command: env::args().collect(), + }); + status.pid = std::process::id(); + status.state = state.to_string(); + status.updated_unix_seconds = unix_seconds_now(); + status.last_update_status = last_update_status; + status.last_error = last_error; + status.next_retry_seconds = next_retry_seconds; + status.pending_scheduled_force = pending_scheduled_force; + status.next_forced_refresh_unix_seconds = system_time_to_unix_seconds(next_forced_refresh_at); + write_daemon_status_file(&status_path, &status) +} + +fn update_daemon_state_only(state_dir: &Path, state: &str) -> anyhow::Result<()> { + let status_path = daemon_status_path(state_dir); + let Some(mut status) = read_daemon_status_file(&status_path)? else { + return Ok(()); + }; + status.pid = std::process::id(); + status.state = state.to_string(); + status.updated_unix_seconds = unix_seconds_now(); + status.next_retry_seconds = None; + write_daemon_status_file(&status_path, &status) +} + +fn read_pid_file(path: &Path) -> anyhow::Result> { + let contents = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + Ok(contents.trim().parse::().ok().filter(|pid| *pid > 0)) +} + +fn daemon_pid_path(output_root: &Path) -> PathBuf { + output_root.join(DAEMON_PID_FILE) +} + +fn daemon_status_path(output_root: &Path) -> PathBuf { + output_root.join(DAEMON_STATUS_FILE) +} + +fn daemon_log_path(output_root: &Path) -> PathBuf { + output_root.join(DAEMON_LOG_FILE) +} + +fn daemon_socket_path(output_root: &Path) -> PathBuf { + output_root.join(DAEMON_SOCKET_FILE) +} + +fn daemon_child_args(options: &CliOptions) -> Vec { + let mut args = Vec::new(); + let config = &options.config; + if config.auto_discover { + args.push("--auto-discover".to_string()); + } + if let Some(source) = config.server_info_source.as_ref() { + match source { + OfficialServerInfoSource::LocalPath(path) => { + args.push("--server-info-path".to_string()); + args.push(path.to_string_lossy().to_string()); + } + OfficialServerInfoSource::OfficialFile(name) => { + args.push("--server-info-file".to_string()); + args.push(name.clone()); + } + OfficialServerInfoSource::OfficialUrl(url) => { + args.push("--server-info-url".to_string()); + args.push(url.clone()); + } + } + } + if let Some(connection_group) = config.connection_group.as_ref() { + args.push("--connection-group".to_string()); + args.push(connection_group.clone()); + } + if let Some(app_version) = config.app_version.as_ref() { + args.push("--app-version".to_string()); + args.push(app_version.clone()); + } + args.push("--launcher-version".to_string()); + args.push(config.launcher_version.clone()); + if let Some(platforms) = config.platforms.as_ref() { + args.push("--platforms".to_string()); + args.push( + platforms + .iter() + .map(|platform| match platform { + PatchPlatform::Windows => "Windows", + PatchPlatform::Android => "Android", + }) + .collect::>() + .join(","), + ); + } + args.push("--output".to_string()); + args.push(config.output_root.to_string_lossy().to_string()); + if let Some(snapshot_path) = config.snapshot_path.as_ref() { + args.push("--snapshot".to_string()); + args.push(snapshot_path.to_string_lossy().to_string()); + } + args.push("--curl".to_string()); + args.push(config.curl_command.to_string_lossy().to_string()); + args.push("--unzip".to_string()); + args.push(config.unzip_command.to_string_lossy().to_string()); + if config.force { + args.push("--force".to_string()); + } + if config.audit_local { + args.push("--audit-local".to_string()); + } else { + args.push("--no-audit-local".to_string()); + } + if config.repair { + args.push("--repair".to_string()); + } else { + args.push("--no-repair".to_string()); + } + args.push("--state-dir".to_string()); + args.push(options.state_dir.to_string_lossy().to_string()); + args.push("--daemon-child".to_string()); + args.push("--watch".to_string()); + args.push("--interval".to_string()); + args.push(format_duration_arg(options.interval)); + args.push("--error-retry".to_string()); + args.push(format_duration_arg(options.error_retry_interval)); + if options.quiet_up_to_date { + args.push("--quiet-up-to-date".to_string()); + } else { + args.push("--no-quiet-up-to-date".to_string()); + } + if options.progress { + args.push("--progress".to_string()); + } else { + args.push("--no-progress".to_string()); + } + if options.output_format == OutputFormat::Json { + args.push("--json".to_string()); + } + if options.banner { + args.push("--banner".to_string()); + } else { + args.push("--no-banner".to_string()); + } + args +} + +fn format_duration_arg(duration: Duration) -> String { + let millis = duration.as_millis(); + if millis % 3_600_000 == 0 { + format!("{}h", millis / 3_600_000) + } else if millis % 60_000 == 0 { + format!("{}m", millis / 60_000) + } else if millis % 1_000 == 0 { + format!("{}s", millis / 1_000) + } else { + format!("{millis}ms") + } +} + +fn unix_seconds_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn system_time_to_unix_seconds(time: SystemTime) -> Option { + time.duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_secs()) +} + +#[cfg(unix)] +fn configure_daemon_command(command: &mut Command) { + use std::os::unix::process::CommandExt; + + unsafe { + command.pre_exec(|| { + if libc::setsid() < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } +} + +#[cfg(not(unix))] +fn configure_daemon_command(_command: &mut Command) {} + +#[cfg(unix)] +fn process_exists(pid: u32) -> bool { + let Ok(pid) = libc::pid_t::try_from(pid) else { + return false; + }; + if pid <= 0 { + return false; + } + unsafe { + libc::kill(pid, 0) == 0 + || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) + } +} + +#[cfg(not(unix))] +fn process_exists(_pid: u32) -> bool { + true +} + +#[cfg(unix)] +fn terminate_process(pid: u32) -> anyhow::Result<()> { + let pid = libc::pid_t::try_from(pid)?; + if pid <= 0 { + return Err(anyhow::anyhow!("无效的后台进程 pid")); + } + let rc = unsafe { libc::kill(pid, libc::SIGTERM) }; + if rc == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error().into()) + } +} + +#[cfg(not(unix))] +fn terminate_process(_pid: u32) -> anyhow::Result<()> { + Err(anyhow::anyhow!("stop 目前只支持 Unix/Linux 平台")) +} + +fn wait_for_process_exit(pid: u32, timeout: Duration) -> bool { + let started_at = Instant::now(); + while started_at.elapsed() < timeout { + if !process_exists(pid) { + return true; + } + thread::sleep(Duration::from_millis(100)); + } + !process_exists(pid) +} + +fn wait_for_rpc_stop_or_terminate(pid: u32, timeout: Duration) -> anyhow::Result { + if wait_for_process_exit(pid, timeout) { + return Ok(false); + } + terminate_process(pid)?; + if !wait_for_process_exit(pid, Duration::from_secs(5)) { + return Err(anyhow::anyhow!("后台进程 pid={pid} 在 SIGTERM 后仍未停止")); + } + Ok(true) } fn next_forced_refresh_at_or_after(now: SystemTime) -> SystemTime { @@ -283,14 +2578,52 @@ impl ProgressLogger { return; } eprintln!( - "[+{} INFO] [{}] {}", + "[+{} 信息] [{}] {}", format_duration(self.started_at.elapsed()), - stage, + localized_stage(stage), message.as_ref() ); } } +fn localized_stage(stage: &str) -> &str { + match stage { + "start" => "启动", + "lock" => "锁", + "bootstrap" => "启动发现", + "launcher" => "启动器", + "bootstrap-cache" => "启动缓存", + "game-main-config" => "游戏配置", + "metadata" => "元数据", + "server-info" => "服务器信息", + "discovery" => "发现", + "markers" => "标记", + "marker" => "标记", + "snapshot" => "快照", + "decision" => "决策", + "plan" => "计划", + "catalog" => "目录", + "inventory" => "清单", + "local-state" => "本地状态", + "audit" => "审计", + "dry-run" => "试运行", + "download" => "下载", + "resource" => "资源", + "finish" => "完成", + "watch" => "常驻", + "daemon" => "后台", + _ => stage, + } +} + +fn is_locked_error(message: &str) -> bool { + message.contains("locked") || message.contains("锁定") +} + +fn localize_error_message(message: &str) -> String { + message.to_string() +} + fn print_startup_banner() { eprintln!("{STARTUP_BANNER}"); } @@ -305,51 +2638,102 @@ fn parse_args() -> anyhow::Result { fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result { let mut args = raw_args.into_iter(); - let binary = args - .next() - .unwrap_or_else(|| "bat-official-sync".to_string()); + let binary = args.next().unwrap_or_else(|| "bat".to_string()); let mut options = CliOptions::default(); while let Some(flag) = args.next() { match flag.as_str() { + "status" => { + ensure_command_not_set(options.command, "status")?; + options.command = CliCommand::Status; + } + "stop" => { + ensure_command_not_set(options.command, "stop")?; + options.command = CliCommand::Stop; + } + "restart" => { + ensure_command_not_set(options.command, "restart")?; + options.command = CliCommand::Restart; + } + "reload" => { + ensure_command_not_set(options.command, "reload")?; + options.command = CliCommand::Reload; + } + "refresh" => { + ensure_command_not_set(options.command, "refresh")?; + options.command = CliCommand::Refresh; + } + "verify" => { + ensure_command_not_set(options.command, "verify")?; + options.command = CliCommand::Verify; + } + "repair" => { + ensure_command_not_set(options.command, "repair")?; + options.command = CliCommand::Repair; + } + "doctor" => { + ensure_command_not_set(options.command, "doctor")?; + options.command = CliCommand::Doctor; + } + "logs" => { + ensure_command_not_set(options.command, "logs")?; + options.command = CliCommand::Logs; + } + "clean-stable" => { + ensure_command_not_set(options.command, "clean-stable")?; + options.command = CliCommand::CleanStable; + } "--auto-discover" => { options.config.auto_discover = true; + options.sync_option_explicit = true; } "--server-info-url" => { options.config.server_info_source = Some(OfficialServerInfoSource::OfficialUrl( next_option_value(&mut args, &flag)?, )); + options.sync_option_explicit = true; } "--server-info-file" => { options.config.server_info_source = Some(OfficialServerInfoSource::OfficialFile( next_option_value(&mut args, &flag)?, )); + options.sync_option_explicit = true; } "--server-info-path" => { options.config.server_info_source = Some(OfficialServerInfoSource::LocalPath( PathBuf::from(next_option_value(&mut args, &flag)?), )); + options.sync_option_explicit = true; } "--connection-group" => { options.config.connection_group = Some(next_option_value(&mut args, &flag)?); + options.sync_option_explicit = true; } "--app-version" => { options.config.app_version = Some(next_option_value(&mut args, &flag)?); + options.sync_option_explicit = true; } "--launcher-version" => { options.config.launcher_version = next_option_value(&mut args, &flag)?; + options.sync_option_explicit = true; } "--platforms" => { let value = next_option_value(&mut args, &flag)?; options.config.platforms = Some(parse_platforms(&value).map_err(anyhow::Error::msg)?); + options.sync_option_explicit = true; } "--output" => { options.config.output_root = PathBuf::from(next_option_value(&mut args, &flag)?); + options.output_explicit = true; + } + "--state-dir" | "--pid-dir" => { + options.state_dir = PathBuf::from(next_option_value(&mut args, &flag)?); } "--snapshot" => { options.config.snapshot_path = Some(PathBuf::from(next_option_value(&mut args, &flag)?)); + options.sync_option_explicit = true; } "--curl" => { options.config.curl_command = PathBuf::from(next_option_value(&mut args, &flag)?); @@ -359,54 +2743,77 @@ fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result } "--dry-run" => { options.config.dry_run = true; + options.sync_option_explicit = true; } "--plan" => { options.config.plan = true; + options.sync_option_explicit = true; } "--force" => { options.config.force = true; + options.sync_option_explicit = true; } "--audit-local" => { options.config.audit_local = true; + options.sync_option_explicit = true; } "--no-audit-local" => { options.config.audit_local = false; + options.sync_option_explicit = true; } "--repair" => { options.config.repair = true; + options.sync_option_explicit = true; } "--no-repair" => { options.config.repair = false; + options.sync_option_explicit = true; } "--watch" => { options.watch = true; + options.sync_option_explicit = true; + } + "--daemon" => { + options.daemon = true; + options.sync_option_explicit = true; + } + "--daemon-child" => { + options.daemon_child = true; + options.watch = true; + options.sync_option_explicit = true; } "--interval" => { options.interval = parse_duration(&next_option_value(&mut args, &flag)?)?; + options.sync_option_explicit = true; } "--interval-seconds" => { let seconds = next_option_value(&mut args, &flag)? .parse::() - .map_err(|error| anyhow::anyhow!("invalid seconds for {flag}: {error}"))?; + .map_err(|error| anyhow::anyhow!("{flag} 的秒数无效:{error}"))?; options.interval = Duration::from_secs(seconds); + options.sync_option_explicit = true; } "--error-retry" => { options.error_retry_interval = parse_duration(&next_option_value(&mut args, &flag)?)?; + options.sync_option_explicit = true; } "--error-retry-seconds" => { let seconds = next_option_value(&mut args, &flag)? .parse::() - .map_err(|error| anyhow::anyhow!("invalid seconds for {flag}: {error}"))?; + .map_err(|error| anyhow::anyhow!("{flag} 的秒数无效:{error}"))?; options.error_retry_interval = Duration::from_secs(seconds); + options.sync_option_explicit = true; } "--quiet-up-to-date" => { options.quiet_up_to_date = true; options.quiet_up_to_date_explicit = true; + options.sync_option_explicit = true; } "--no-quiet-up-to-date" => { options.quiet_up_to_date = false; options.quiet_up_to_date_explicit = true; + options.sync_option_explicit = true; } "--progress" => { options.progress = true; @@ -416,69 +2823,237 @@ fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result options.progress = false; options.banner = false; } + "--json" => { + options.output_format = OutputFormat::Json; + } + "--human" => { + options.output_format = OutputFormat::Human; + } "--banner" => { options.banner = true; } "--no-banner" => { options.banner = false; } + "--tail" => { + options.tail_lines = next_option_value(&mut args, &flag)? + .parse::() + .map_err(|error| anyhow::anyhow!("{flag} 的行数无效:{error}"))?; + if options.tail_lines == 0 { + return Err(anyhow::anyhow!("--tail 必须大于 0")); + } + } "--help" | "-h" => { print_usage(&binary); std::process::exit(0); } - _ => return Err(anyhow::anyhow!("unknown argument: {flag}")), + _ => return Err(anyhow::anyhow!("未知参数:{flag}")), } } - if options.watch && options.config.dry_run { - return Err(anyhow::anyhow!("watch mode cannot be used with --dry-run")); + match options.command { + CliCommand::Status | CliCommand::Stop | CliCommand::Logs => { + if options.sync_option_explicit + || options.output_explicit + || tools_are_non_default(&options.config) + { + return Err(anyhow::anyhow!( + "status/stop/logs 只读取 --state-dir;资源同步参数和输出目录无关" + )); + } + options.progress = false; + options.banner = false; + } + CliCommand::Doctor | CliCommand::CleanStable => { + if options.sync_option_explicit { + return Err(anyhow::anyhow!( + "doctor/clean-stable 只接受 --output、--state-dir、--curl 和 --unzip 等诊断参数" + )); + } + options.progress = false; + options.banner = false; + } + CliCommand::Refresh | CliCommand::Verify | CliCommand::Repair => { + if !options.config.auto_discover + && options.config.server_info_source.is_none() + && options.config.connection_group.is_none() + && options.config.app_version.is_none() + { + options.config.auto_discover = true; + } + if matches!(options.command, CliCommand::Verify) { + options.config.dry_run = true; + options.config.plan = true; + options.config.audit_local = true; + options.config.repair = false; + } + } + CliCommand::Restart | CliCommand::Reload => { + if (options.sync_option_explicit + || options.output_explicit + || tools_are_non_default(&options.config)) + && !options.config.auto_discover + && options.config.server_info_source.is_none() + && options.config.connection_group.is_none() + && options.config.app_version.is_none() + { + options.config.auto_discover = true; + } + } + CliCommand::Run => {} } - if options.interval.is_zero() { - return Err(anyhow::anyhow!("watch interval must be greater than zero")); - } - if options.error_retry_interval.is_zero() { + if options.daemon && options.watch { return Err(anyhow::anyhow!( - "watch error retry interval must be greater than zero" + "--daemon 会自动启动 watch 模式,不要同时传 --watch" )); } - if options.watch && !options.quiet_up_to_date_explicit { + if (options.watch || options.daemon || options.daemon_child) && options.config.dry_run { + return Err(anyhow::anyhow!( + "watch/daemon 模式不能和 --dry-run 同时使用" + )); + } + if options.interval.is_zero() { + return Err(anyhow::anyhow!("watch 检查间隔必须大于 0")); + } + if options.error_retry_interval.is_zero() { + return Err(anyhow::anyhow!("watch 失败重试间隔必须大于 0")); + } + if (options.watch || options.daemon || options.daemon_child) + && !options.quiet_up_to_date_explicit + { options.quiet_up_to_date = true; } + if matches!(options.command, CliCommand::Verify) { + if options.config.force { + return Err(anyhow::anyhow!( + "verify 只验证本地资源,不能和 --force 同时使用" + )); + } + if options.config.repair == false && options.config.audit_local == false { + return Err(anyhow::anyhow!( + "verify 必须启用本地审计;不要同时传 --no-repair --no-audit-local" + )); + } + } + if matches!(options.command, CliCommand::Restart | CliCommand::Reload) + && (options.config.force || options.config.dry_run) + { + return Err(anyhow::anyhow!( + "restart/reload 不能和 --force 或 --dry-run 同时使用" + )); + } + if matches!(options.command, CliCommand::Logs) && options.tail_lines == 0 { + return Err(anyhow::anyhow!("logs 的 --tail 必须大于 0")); + } Ok(options) } +fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()> { + if command == CliCommand::Run { + Ok(()) + } else { + Err(anyhow::anyhow!("不支持同时指定多个命令;意外命令:{next}")) + } +} + +fn tools_are_non_default(config: &OfficialUpdateConfig) -> bool { + config.curl_command != PathBuf::from("curl") || config.unzip_command != PathBuf::from("unzip") +} + fn next_option_value( args: &mut impl Iterator, flag: &str, ) -> anyhow::Result { args.next() - .ok_or_else(|| anyhow::anyhow!("missing value for {flag}")) + .ok_or_else(|| anyhow::anyhow!("{flag} 缺少参数值")) } fn print_usage(binary: &str) { + eprintln!("BlueArchiveToolkit official resource sync"); + eprintln!(); + eprintln!("Usage:"); + eprintln!(" {binary} [OPTIONS]"); + eprintln!(" {binary} [OPTIONS]"); + eprintln!(); + eprintln!("Commands:"); + eprintln!(" refresh Run one update check, or ask a live daemon to refresh"); + eprintln!(" verify Verify remote plan, local manifest, and official seed hashes"); + eprintln!(" repair Redownload resources that fail local verification"); + eprintln!(" status Show daemon state"); + eprintln!(" stop Stop daemon"); eprintln!( - "usage: {binary} [--auto-discover | --server-info-url | --server-info-file | --server-info-path ] \ - [--app-version 1.70.0] [--connection-group ] [--launcher-version 1.7.2] \ - [--platforms Windows,Android] [--output official_update_output] [--snapshot official-sync-snapshot.json] \ - [--curl curl] [--unzip unzip] [--dry-run] [--plan] [--force] [--audit-local|--no-audit-local] [--repair|--no-repair] \ - [--watch] [--interval 1h|60m|3600s] [--error-retry 60s] [--quiet-up-to-date|--no-quiet-up-to-date] [--progress|--no-progress] [--banner|--no-banner]" + " restart Restart daemon, reusing saved args unless explicit args are passed" ); + eprintln!(" reload Ask daemon to rediscover metadata and force refresh"); + eprintln!(" logs Show daemon log tail"); + eprintln!(" doctor Run runtime diagnostics"); + eprintln!(" clean-stable Remove .part/.tmp/stale lock, pid, and socket files"); + eprintln!(); + eprintln!("Examples:"); + eprintln!(" {binary} --auto-discover --dry-run"); + eprintln!(" {binary} --auto-discover --watch"); + eprintln!(" {binary} --auto-discover --daemon"); + eprintln!(" {binary} status"); + eprintln!(" {binary} refresh --force --json"); + eprintln!(); + eprintln!("Discovery:"); eprintln!( - "watch mode also runs scheduled force refreshes every day at {DAILY_FORCED_REFRESH_LABEL}" + " --auto-discover Discover app-version, server-info, connection-group" ); + eprintln!(" --server-info-url Use an official server-info URL"); + eprintln!(" --server-info-file Use an official server-info file name"); + eprintln!(" --server-info-path Use a local server-info JSON file"); + eprintln!(" --app-version Override app version"); + eprintln!(" --connection-group Override connection group"); + eprintln!(" --launcher-version Launcher metadata API version (default: 1.7.2)"); + eprintln!(); + eprintln!("Sync:"); + eprintln!(" --platforms Platforms, e.g. Windows,Android"); + eprintln!(" --output Resource output dir (default: ./bat-resources)"); + eprintln!(" --snapshot Snapshot path (default: /official-sync-snapshot.json)"); + eprintln!(" --curl curl executable (default: curl)"); + eprintln!(" --unzip unzip executable (default: unzip)"); + eprintln!(" --dry-run Do not write sync state"); + eprintln!(" --plan Include planned URLs in dry-run"); + eprintln!(" --force Force download/refresh"); + eprintln!(" --audit-local | --no-audit-local Enable/disable local manifest audit"); + eprintln!(" --repair | --no-repair Enable/disable automatic repair"); + eprintln!(); + eprintln!("Daemon:"); + eprintln!(" --watch Run in foreground loop"); + eprintln!(" --daemon Start detached watch process"); + eprintln!(" --state-dir Daemon state dir (default: /tmp/bat-pid)"); + eprintln!(" --interval Normal check interval (default: 1h)"); + eprintln!(" --error-retry Retry interval after error (default: 60s)"); + eprintln!(" --quiet-up-to-date Suppress clean up-to-date reports"); + eprintln!(" --no-quiet-up-to-date Always print reports"); + eprintln!(" --tail Log lines for logs command (default: 200)"); + eprintln!(); + eprintln!("Output:"); + eprintln!(" --human Human-readable output (default)"); + eprintln!(" --json Stable JSON output for scripts"); + eprintln!(" --progress | --no-progress Enable/disable stderr progress logs"); + eprintln!(" --banner | --no-banner Enable/disable startup banner"); + eprintln!(" -h, --help Show this help"); + eprintln!(); + eprintln!("Defaults:"); + eprintln!(" platforms: Windows,Android"); + eprintln!(" resource output: ./bat-resources"); + eprintln!(" daemon state: /tmp/bat-pid (bat.sock, bat.pid, bat-status.json, bat-daemon.log)"); + eprintln!(" forced refresh: {DAILY_FORCED_REFRESH_LABEL}"); } fn parse_duration(value: &str) -> anyhow::Result { let value = value.trim(); if value.is_empty() { - return Err(anyhow::anyhow!("duration must not be empty")); + return Err(anyhow::anyhow!("时间间隔不能为空")); } let (number, multiplier) = if let Some(number) = value.strip_suffix("ms") { let millis = number .parse::() - .map_err(|error| anyhow::anyhow!("invalid millisecond duration {value}: {error}"))?; + .map_err(|error| anyhow::anyhow!("毫秒时间间隔无效:{value}:{error}"))?; return Ok(Duration::from_millis(millis)); } else if let Some(number) = value.strip_suffix('s') { (number, 1) @@ -492,7 +3067,7 @@ fn parse_duration(value: &str) -> anyhow::Result { let amount = number .parse::() - .map_err(|error| anyhow::anyhow!("invalid duration {value}: {error}"))?; + .map_err(|error| anyhow::anyhow!("时间间隔无效:{value}:{error}"))?; Ok(Duration::from_secs(amount.saturating_mul(multiplier))) } @@ -517,7 +3092,7 @@ fn parse_platform(value: &str) -> Result { match value.to_ascii_lowercase().as_str() { "windows" | "win" => Ok(PatchPlatform::Windows), "android" => Ok(PatchPlatform::Android), - _ => Err(format!("unsupported platform: {value}")), + _ => Err(format!("不支持的平台:{value}")), } } @@ -532,7 +3107,7 @@ mod tests { #[test] fn parses_auto_discover_sync_args() { let options = parse(&[ - "bat-official-sync", + "bat", "--auto-discover", "--platforms", "Windows,Android", @@ -570,12 +3145,15 @@ mod tests { assert!(!options.quiet_up_to_date_explicit); assert!(options.progress); assert!(options.banner); + assert_eq!(options.output_format, OutputFormat::Human); + assert_eq!(config.output_root, PathBuf::from("./bat-resources")); + assert_eq!(options.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR)); } #[test] fn parses_explicit_source_and_disable_repair() { let options = parse(&[ - "bat-official-sync", + "bat", "--server-info-url", "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json", "--connection-group", @@ -605,14 +3183,7 @@ mod tests { #[test] fn parses_watch_defaults_to_one_hour_and_quiet_up_to_date() { - let options = parse(&[ - "bat-official-sync", - "--auto-discover", - "--watch", - "--interval", - "30m", - ]) - .unwrap(); + let options = parse(&["bat", "--auto-discover", "--watch", "--interval", "30m"]).unwrap(); assert!(options.watch); assert_eq!(options.interval, Duration::from_secs(30 * 60)); @@ -625,51 +3196,183 @@ mod tests { } #[test] - fn parses_explicit_watch_error_retry_interval() { + fn parses_daemon_mode_as_background_watch() { let options = parse(&[ - "bat-official-sync", - "--watch", + "bat", + "--auto-discover", + "--daemon", + "--output", + "/tmp/daemon-output", "--interval", - "1h", - "--error-retry", - "45s", + "30m", ]) .unwrap(); + + assert_eq!(options.command, CliCommand::Run); + assert!(options.daemon); + assert!(!options.watch); + assert_eq!( + options.config.output_root, + PathBuf::from("/tmp/daemon-output") + ); + assert_eq!(options.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR)); + assert_eq!(options.interval, Duration::from_secs(30 * 60)); + assert!(options.quiet_up_to_date); + } + + #[test] + fn parses_output_format_flags() { + let options = parse(&["bat", "status", "--json"]).unwrap(); + assert_eq!(options.command, CliCommand::Status); + assert_eq!(options.output_format, OutputFormat::Json); + + let options = parse(&["bat", "status", "--json", "--human"]).unwrap(); + assert_eq!(options.output_format, OutputFormat::Human); + } + + #[test] + fn parses_status_and_stop_commands() { + let status = parse(&["bat", "status"]).unwrap(); + assert_eq!(status.command, CliCommand::Status); + assert!(!status.watch); + assert!(!status.daemon); + assert!(!status.progress); + assert!(!status.banner); + assert_eq!(status.config.output_root, PathBuf::from("./bat-resources")); + assert_eq!(status.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR)); + + let stop = parse(&["bat", "stop", "--state-dir", "/tmp/custom-bat-pid"]).unwrap(); + assert_eq!(stop.command, CliCommand::Stop); + assert_eq!(stop.config.output_root, PathBuf::from("./bat-resources")); + assert_eq!(stop.state_dir, PathBuf::from("/tmp/custom-bat-pid")); + } + + #[test] + fn parses_management_and_resource_commands() { + let refresh = parse(&["bat", "refresh", "--force"]).unwrap(); + assert_eq!(refresh.command, CliCommand::Refresh); + assert!(refresh.config.auto_discover); + assert!(refresh.config.force); + + let verify = parse(&["bat", "verify"]).unwrap(); + assert_eq!(verify.command, CliCommand::Verify); + assert!(verify.config.auto_discover); + assert!(verify.config.dry_run); + assert!(verify.config.plan); + assert!(!verify.config.repair); + + let repair = parse(&["bat", "repair"]).unwrap(); + assert_eq!(repair.command, CliCommand::Repair); + assert!(repair.config.auto_discover); + + for (value, expected) in [ + ("restart", CliCommand::Restart), + ("reload", CliCommand::Reload), + ("logs", CliCommand::Logs), + ("doctor", CliCommand::Doctor), + ("clean-stable", CliCommand::CleanStable), + ] { + let options = parse(&["bat", value]).unwrap(); + assert_eq!(options.command, expected); + } + } + + #[test] + fn parses_logs_tail_and_rejects_force_for_verify() { + let options = parse(&["bat", "logs", "--tail", "25"]).unwrap(); + assert_eq!(options.tail_lines, 25); + + let error = parse(&["bat", "verify", "--force"]).unwrap_err(); + assert!(error.to_string().contains("不能和 --force")); + } + + #[test] + fn restart_with_explicit_options_defaults_to_auto_discover() { + let options = parse(&["bat", "restart", "--output", "/tmp/bat-resources"]).unwrap(); + + assert_eq!(options.command, CliCommand::Restart); + assert!(options.output_explicit); + assert!(options.config.auto_discover); + } + + #[test] + fn daemon_child_args_preserve_sync_options() { + let options = parse(&[ + "bat", + "--auto-discover", + "--daemon", + "--platforms", + "Windows,Android", + "--output", + "/tmp/daemon-output", + "--state-dir", + "/tmp/daemon-state", + "--curl", + "/usr/bin/curl", + "--unzip", + "/usr/bin/unzip", + "--interval", + "30m", + "--error-retry", + "45s", + "--json", + "--no-banner", + ]) + .unwrap(); + + let args = daemon_child_args(&options); + assert!(args.contains(&"--watch".to_string())); + assert!(args.contains(&"--daemon-child".to_string())); + assert!(!args.contains(&"--daemon".to_string())); + assert!(args + .windows(2) + .any(|pair| pair == ["--output", "/tmp/daemon-output"])); + assert!(args + .windows(2) + .any(|pair| pair == ["--state-dir", "/tmp/daemon-state"])); + assert!(args + .windows(2) + .any(|pair| pair == ["--platforms", "Windows,Android"])); + assert!(args.windows(2).any(|pair| pair == ["--interval", "30m"])); + assert!(args.windows(2).any(|pair| pair == ["--error-retry", "45s"])); + assert!(args.contains(&"--quiet-up-to-date".to_string())); + assert!(args.contains(&"--json".to_string())); + assert!(args.contains(&"--no-banner".to_string())); + } + + #[test] + fn parses_explicit_watch_error_retry_interval() { + let options = + parse(&["bat", "--watch", "--interval", "1h", "--error-retry", "45s"]).unwrap(); assert_eq!(options.interval, Duration::from_secs(3600)); assert_eq!(options.error_retry_interval, Duration::from_secs(45)); - let options = parse(&[ - "bat-official-sync", - "--watch", - "--error-retry-seconds", - "30", - ]) - .unwrap(); + let options = parse(&["bat", "--watch", "--error-retry-seconds", "30"]).unwrap(); assert_eq!(options.error_retry_interval, Duration::from_secs(30)); } #[test] fn parses_progress_flags() { - let options = parse(&["bat-official-sync"]).unwrap(); + let options = parse(&["bat"]).unwrap(); assert!(options.progress); assert!(options.banner); - let options = parse(&["bat-official-sync", "--no-progress"]).unwrap(); + let options = parse(&["bat", "--no-progress"]).unwrap(); assert!(!options.progress); assert!(!options.banner); - let options = parse(&["bat-official-sync", "--no-progress", "--progress"]).unwrap(); + let options = parse(&["bat", "--no-progress", "--progress"]).unwrap(); assert!(options.progress); assert!(options.banner); } #[test] fn parses_banner_flags() { - let options = parse(&["bat-official-sync", "--no-banner"]).unwrap(); + let options = parse(&["bat", "--no-banner"]).unwrap(); assert!(options.progress); assert!(!options.banner); - let options = parse(&["bat-official-sync", "--no-progress", "--banner"]).unwrap(); + let options = parse(&["bat", "--no-progress", "--banner"]).unwrap(); assert!(!options.progress); assert!(options.banner); } @@ -679,10 +3382,98 @@ mod tests { assert!(STARTUP_BANNER.contains("BlueArchiveToolkit")); } + #[test] + fn daemon_socket_path_uses_state_dir() { + assert_eq!( + daemon_socket_path(Path::new("/tmp/custom-bat-pid")), + PathBuf::from("/tmp/custom-bat-pid/bat.sock") + ); + } + + #[test] + fn rpc_tail_param_defaults_and_rejects_zero() { + assert_eq!(rpc_tail_param(None, 200).unwrap(), 200); + assert_eq!( + rpc_tail_param(Some(&serde_json::json!({ "tail": 25 })), 200).unwrap(), + 25 + ); + let error = rpc_tail_param(Some(&serde_json::json!({ "tail": 0 })), 200).unwrap_err(); + assert!(error.to_string().contains("大于 0")); + } + + #[test] + fn json_rpc_request_and_response_are_line_protocol_friendly() { + let request: JsonRpcRequest = serde_json::from_value(serde_json::json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "bat.refresh", + "params": { "force": true } + })) + .unwrap(); + assert_eq!(request.method, RPC_METHOD_REFRESH); + assert_eq!(rpc_bool_param(request.params.as_ref(), "force"), Some(true)); + + let response = json_rpc_result( + Some(serde_json::json!(7)), + serde_json::json!({ "ok": true }), + ); + let serialized = serde_json::to_string(&response).unwrap(); + assert!(serialized.contains("\"jsonrpc\":\"2.0\"")); + assert!(serialized.contains("\"ok\":true")); + assert!(!serialized.contains('\n')); + } + + #[test] + fn daemon_control_wake_handles_refresh_reload_and_stop() { + let control = new_daemon_control(); + daemon_control_request_refresh(&control, true); + assert_eq!( + wait_for_daemon_wake(Some(&control), Duration::from_millis(1)), + DaemonWake::Refresh { force: true } + ); + + daemon_control_request_reload(&control); + assert_eq!( + wait_for_daemon_wake(Some(&control), Duration::from_millis(1)), + DaemonWake::Reload + ); + + daemon_control_request_refresh(&control, false); + daemon_control_mark_stop_requested(&control); + daemon_control_notify_all(&control); + assert_eq!( + wait_for_daemon_wake(Some(&control), Duration::from_millis(1)), + DaemonWake::Stop + ); + } + + #[test] + fn rpc_stop_wait_reports_already_stopped_without_signal() { + assert!(!wait_for_rpc_stop_or_terminate(0, Duration::from_millis(1)).unwrap()); + } + + #[test] + fn refresh_rpc_selection_only_for_default_daemon_shape() { + let options = parse(&["bat", "refresh"]).unwrap(); + assert!(refresh_should_use_daemon_rpc(&options, "refresh")); + + let options = parse(&["bat", "refresh", "--force"]).unwrap(); + assert!(refresh_should_use_daemon_rpc(&options, "refresh")); + + let options = parse(&["bat", "refresh", "--output", "/tmp/other"]).unwrap(); + assert!(!refresh_should_use_daemon_rpc(&options, "refresh")); + + let options = parse(&["bat", "refresh", "--server-info-file", "ProdNotice.json"]).unwrap(); + assert!(!refresh_should_use_daemon_rpc(&options, "refresh")); + + let options = parse(&["bat", "repair"]).unwrap(); + assert!(!refresh_should_use_daemon_rpc(&options, "repair")); + } + #[test] fn explicit_no_quiet_up_to_date_overrides_watch_default() { let options = parse(&[ - "bat-official-sync", + "bat", "--watch", "--no-quiet-up-to-date", "--interval", @@ -697,16 +3488,23 @@ mod tests { #[test] fn rejects_watch_dry_run_and_zero_interval() { - let error = parse(&["bat-official-sync", "--watch", "--dry-run"]).unwrap_err(); - assert!(error.to_string().contains("watch mode")); + let error = parse(&["bat", "--watch", "--dry-run"]).unwrap_err(); + assert!(error.to_string().contains("不能和 --dry-run")); - let error = - parse(&["bat-official-sync", "--watch", "--interval-seconds", "0"]).unwrap_err(); - assert!(error.to_string().contains("greater than zero")); + let error = parse(&["bat", "--daemon", "--watch"]).unwrap_err(); + assert!(error.to_string().contains("不要同时传 --watch")); - let error = - parse(&["bat-official-sync", "--watch", "--error-retry-seconds", "0"]).unwrap_err(); - assert!(error.to_string().contains("error retry interval")); + let error = parse(&["bat", "status", "--watch"]).unwrap_err(); + assert!(error.to_string().contains("status/stop")); + + let error = parse(&["bat", "status", "--output", "/tmp/resources"]).unwrap_err(); + assert!(error.to_string().contains("--state-dir")); + + let error = parse(&["bat", "--watch", "--interval-seconds", "0"]).unwrap_err(); + assert!(error.to_string().contains("必须大于 0")); + + let error = parse(&["bat", "--watch", "--error-retry-seconds", "0"]).unwrap_err(); + assert!(error.to_string().contains("失败重试间隔")); } #[test] @@ -718,6 +3516,14 @@ mod tests { assert_eq!(parse_duration("3600").unwrap(), Duration::from_secs(3600)); } + #[test] + fn formats_duration_args_for_child_process() { + assert_eq!(format_duration_arg(Duration::from_secs(3600)), "1h"); + assert_eq!(format_duration_arg(Duration::from_secs(90 * 60)), "90m"); + assert_eq!(format_duration_arg(Duration::from_secs(45)), "45s"); + assert_eq!(format_duration_arg(Duration::from_millis(250)), "250ms"); + } + #[test] fn scheduled_forced_refresh_uses_beijing_local_times() { let day = 20_000; diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index 5c871ab..ead48fb 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -24,9 +24,9 @@ pub use cas::FileSystemCasRepository; pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService}; pub use official_download::{ OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport, - OfficialLocalManifestAuditStatus, OfficialResourcePullItem, OfficialResourcePullProgress, - OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService, - OfficialResourcePullStatus, + OfficialLocalManifestAuditStatus, OfficialLocalVerificationReport, OfficialResourcePullItem, + OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullReport, + OfficialResourcePullService, OfficialResourcePullStatus, }; pub use official_game_main_config::OfficialGameMainConfigBootstrapService; pub use official_launcher::{ diff --git a/infrastructure/src/official_download.rs b/infrastructure/src/official_download.rs index 2a573e2..7fe4678 100644 --- a/infrastructure/src/official_download.rs +++ b/infrastructure/src/official_download.rs @@ -226,6 +226,19 @@ impl OfficialLocalManifestAuditStatus { pub fn is_verified(self) -> bool { self == Self::Verified } + + /// Returns a stable status label for CLI and JSON reports. + pub fn as_str(self) -> &'static str { + match self { + Self::Verified => "verified", + Self::MissingManifestEntry => "missing_manifest_entry", + Self::UrlMismatch => "url_mismatch", + Self::DestinationMismatch => "destination_mismatch", + Self::MissingFile => "missing_file", + Self::SizeMismatch => "size_mismatch", + Self::Blake3Mismatch => "blake3_mismatch", + } + } } /// Local download-manifest audit result for one expected official URL. @@ -276,6 +289,43 @@ impl OfficialLocalManifestAuditReport { } } +/// Full local verification report for all entries currently recorded in the +/// official download manifest. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OfficialLocalVerificationReport { + /// Per-entry manifest audit results. + pub items: Vec, + /// Official seed hash pairs found in the local manifest. + pub official_hash_pair_count: usize, + /// Official seed hash pairs that passed xxHash32 verification. + pub official_hash_verified_count: usize, + /// Official seed hash verification failures. + pub official_hash_errors: Vec, +} + +impl OfficialLocalVerificationReport { + /// Returns true when every local manifest entry and every local official + /// seed hash pair passed verification. + pub fn is_clean(&self) -> bool { + self.items.iter().all(|item| item.status.is_verified()) + && self.official_hash_errors.is_empty() + && self.official_hash_verified_count == self.official_hash_pair_count + } + + /// Returns the number of manifest entries that passed verification. + pub fn verified_count(&self) -> usize { + self.items + .iter() + .filter(|item| item.status.is_verified()) + .count() + } + + /// Returns the number of manifest entries that need attention. + pub fn failure_count(&self) -> usize { + self.items.len().saturating_sub(self.verified_count()) + } +} + /// Existing local state for an official pull plan. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct OfficialLocalResourceState { @@ -362,7 +412,7 @@ impl OfficialResourcePullService { ) -> Result { fs::create_dir_all(&self.output_root).map_err(|error| { format!( - "Failed to create output root {}: {error}", + "创建资源输出目录失败 {}:{error}", self.output_root.display() ) })?; @@ -378,7 +428,7 @@ impl OfficialResourcePullService { let mut processed_urls = HashSet::::new(); for (offset, url) in urls.into_iter().enumerate() { if should_cancel() { - return Err("official resource pull interrupted by shutdown request".to_string()); + return Err("官方资源拉取已被停止请求中断".to_string()); } let index = offset + 1; @@ -389,16 +439,13 @@ impl OfficialResourcePullService { )); if !is_official_yostar_jp_url(&url) { - return Err(format!("Refusing to download non-official URL: {url}")); + return Err(format!("拒绝下载非官方 URL:{url}")); } let destination = self.destination_for_url(&url)?; if let Some(parent) = destination.parent() { fs::create_dir_all(parent).map_err(|error| { - format!( - "Failed to create destination directory {}: {error}", - parent.display() - ) + format!("创建下载目标目录失败 {}:{error}", parent.display()) })?; } @@ -443,7 +490,7 @@ impl OfficialResourcePullService { } if should_cancel() { - return Err("official resource pull interrupted by shutdown request".to_string()); + return Err("官方资源拉取已被停止请求中断".to_string()); } self.verify_all_official_hashes_are_complete(&official_hash_pairs, &verified_hash_urls)?; @@ -473,6 +520,42 @@ impl OfficialResourcePullService { Ok(OfficialLocalManifestAuditReport { items }) } + /// Verifies every entry currently present in the local download manifest. + /// + /// This is intentionally network-free. It validates each recorded file + /// using the local manifest's size and BLAKE3 digest, then verifies every + /// local seed `.bytes`/`.hash` pair that is present using the official + /// decimal xxHash32 rule. + pub fn verify_local_download_manifest( + &self, + ) -> Result { + let manifest = self.read_download_manifest()?; + let urls = manifest.entries.keys().cloned().collect::>(); + let mut items = Vec::with_capacity(urls.len()); + + for url in urls { + let destination = self.destination_for_url(&url)?; + items.push(self.audit_one(&url, destination, &manifest)?); + } + + let pairs = local_manifest_seed_hash_pairs(&manifest); + let mut official_hash_verified_count = 0; + let mut official_hash_errors = Vec::new(); + for pair in &pairs { + match self.verify_official_seed_hash_pair_from_disk(pair) { + Ok(_) => official_hash_verified_count += 1, + Err(error) => official_hash_errors.push(error), + } + } + + Ok(OfficialLocalVerificationReport { + items, + official_hash_pair_count: pairs.len(), + official_hash_verified_count, + official_hash_errors, + }) + } + /// Returns whether the current output root already contains local state for /// this plan. This is intentionally cheaper than a full audit and is used /// to choose between audit-then-repair and first-time full pull flows. @@ -508,7 +591,7 @@ impl OfficialResourcePullService { /// `MediaCatalog.bytes`. pub fn fetch_bytes(&self, url: &str) -> Result, String> { if !is_official_yostar_jp_url(url) { - return Err(format!("Refusing to fetch non-official URL: {url}")); + return Err(format!("拒绝拉取非官方 URL:{url}")); } let attempts = self.retry_attempts.max(1); @@ -517,12 +600,12 @@ impl OfficialResourcePullService { match self.fetch_bytes_once(url) { Ok(bytes) => return Ok(bytes), Err(error) => { - last_error = Some(format!("attempt {attempt}/{attempts}: {error}")); + last_error = Some(format!("第 {attempt}/{attempts} 次尝试失败:{error}")); } } } - Err(last_error.unwrap_or_else(|| format!("curl failed for {url} without an attempt"))) + Err(last_error.unwrap_or_else(|| format!("curl 未执行就已失败:{url}"))) } fn fetch_bytes_once(&self, url: &str) -> Result, String> { @@ -536,14 +619,14 @@ impl OfficialResourcePullService { .output() .map_err(|error| { format!( - "Failed to launch curl command {}: {error}", + "启动 curl 命令失败 {}:{error}", self.curl_command.display() ) })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("curl failed for {url}: {}", stderr.trim())); + return Err(format!("curl 拉取失败 {url}:{}", stderr.trim())); } Ok(output.stdout) @@ -559,9 +642,7 @@ impl OfficialResourcePullService { let _ = fs::remove_file(&partial); self.download_one(url, &partial, false) .map_err(|retry_error| { - format!( - "resume failed for {url}: {error}; clean retry also failed: {retry_error}" - ) + format!("续传失败 {url}:{error};清理后重新下载也失败:{retry_error}") })?; } } else { @@ -570,28 +651,20 @@ impl OfficialResourcePullService { if file_len_if_exists(destination)?.is_some() { fs::remove_file(destination).map_err(|error| { - format!( - "Failed to replace existing destination {}: {error}", - destination.display() - ) + format!("替换已有目标文件失败 {}:{error}", destination.display()) })?; } fs::rename(&partial, destination).map_err(|error| { format!( - "Failed to move partial download {} -> {}: {error}", + "移动临时下载文件失败 {} -> {}:{error}", partial.display(), destination.display() ) })?; let bytes = fs::metadata(destination) - .map_err(|error| { - format!( - "Downloaded file missing at {}: {error}", - destination.display() - ) - })? + .map_err(|error| format!("下载完成后目标文件缺失 {}:{error}", destination.display()))? .len(); Ok(PullOneResult { @@ -612,17 +685,13 @@ impl OfficialResourcePullService { match self.download_one_once(url, destination, resume) { Ok(()) => return Ok(()), Err(error) => { - last_error = Some(format!("attempt {attempt}/{attempts}: {error}")); + last_error = Some(format!("第 {attempt}/{attempts} 次尝试失败:{error}")); } } } - Err(last_error.unwrap_or_else(|| { - format!( - "curl failed for {url} -> {} without an attempt", - destination.display() - ) - })) + Err(last_error + .unwrap_or_else(|| format!("curl 未执行就已失败:{url} -> {}", destination.display()))) } fn download_one_once(&self, url: &str, destination: &Path, resume: bool) -> Result<(), String> { @@ -641,7 +710,7 @@ impl OfficialResourcePullService { let output = command.output().map_err(|error| { format!( - "Failed to launch curl command {}: {error}", + "启动 curl 命令失败 {}:{error}", self.curl_command.display() ) })?; @@ -649,7 +718,7 @@ impl OfficialResourcePullService { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!( - "curl failed for {url} -> {}: {}", + "curl 下载失败 {url} -> {}:{}", destination.display(), stderr.trim() )); @@ -687,7 +756,7 @@ impl OfficialResourcePullService { self.invalidate_download_manifest_hash_pair(manifest, pair) { return Err(format!( - "{error}; additionally failed to invalidate local manifest entries for official hash pair: {cleanup_error}" + "{error};同时清理官方 hash 对应的本地 manifest 条目失败:{cleanup_error}" )); } @@ -716,13 +785,13 @@ impl OfficialResourcePullService { let hash_path = self.destination_for_url(&pair.hash_url)?; let data = fs::read(&data_path).map_err(|error| { format!( - "Failed to read official hash data file {}: {error}", + "读取官方 hash 对应数据文件失败 {}:{error}", data_path.display() ) })?; let hash = fs::read(&hash_path).map_err(|error| { format!( - "Failed to read official hash sidecar {}: {error}", + "读取官方 hash sidecar 失败 {}:{error}", hash_path.display() ) })?; @@ -748,7 +817,7 @@ impl OfficialResourcePullService { for pair in pairs { if !verified_hash_urls.contains(&pair.hash_url) { return Err(format!( - "official hash pair was not verified for {} using {}", + "官方 hash 对未完成校验:数据={} hash={}", pair.data_url, pair.hash_url )); } @@ -766,23 +835,18 @@ impl OfficialResourcePullService { } Err(error) => { return Err(format!( - "Failed to read download manifest {}: {error}", + "读取下载 manifest 失败 {}:{error}", path.display() )); } }; - let manifest: OfficialDownloadManifest = - serde_json::from_slice(&bytes).map_err(|error| { - format!( - "Failed to parse download manifest {}: {error}", - path.display() - ) - })?; + let manifest: OfficialDownloadManifest = serde_json::from_slice(&bytes) + .map_err(|error| format!("解析下载 manifest 失败 {}:{error}", path.display()))?; if manifest.version != DOWNLOAD_MANIFEST_VERSION { return Err(format!( - "Unsupported download manifest version {} in {}", + "不支持的下载 manifest 版本 {},文件 {}", manifest.version, path.display() )); @@ -795,29 +859,22 @@ impl OfficialResourcePullService { let path = self.download_manifest_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|error| { - format!( - "Failed to create download manifest directory {}: {error}", - parent.display() - ) + format!("创建下载 manifest 目录失败 {}:{error}", parent.display()) })?; } let temporary = path.with_extension("json.tmp"); - let bytes = serde_json::to_vec_pretty(manifest).map_err(|error| { - format!( - "Failed to serialize download manifest {}: {error}", - path.display() - ) - })?; + let bytes = serde_json::to_vec_pretty(manifest) + .map_err(|error| format!("序列化下载 manifest 失败 {}:{error}", path.display()))?; fs::write(&temporary, bytes).map_err(|error| { format!( - "Failed to write temporary download manifest {}: {error}", + "写入临时下载 manifest 失败 {}:{error}", temporary.display() ) })?; fs::rename(&temporary, &path).map_err(|error| { format!( - "Failed to move download manifest {} -> {}: {error}", + "移动下载 manifest 失败 {} -> {}:{error}", temporary.display(), path.display() ) @@ -871,12 +928,7 @@ impl OfficialResourcePullService { destination: &Path, ) -> Result<(), String> { let bytes = fs::metadata(destination) - .map_err(|error| { - format!( - "Failed to inspect downloaded file {}: {error}", - destination.display() - ) - })? + .map_err(|error| format!("读取已下载文件信息失败 {}:{error}", destination.display()))? .len(); let digest = blake3_file_hex(destination)?; let relative_destination = self.relative_destination(destination)?; @@ -899,7 +951,7 @@ impl OfficialResourcePullService { .strip_prefix(&self.output_root) .map_err(|error| { format!( - "Destination {} is not under output root {}: {error}", + "目标路径 {} 不在输出目录 {} 下:{error}", destination.display(), self.output_root.display() ) @@ -1013,15 +1065,15 @@ impl OfficialResourcePullService { fn destination_for_url(&self, url: &str) -> Result { if !is_official_yostar_jp_url(url) { - return Err(format!("URL is not an official JP host: {url}")); + return Err(format!("URL 不是官方 JP host:{url}")); } let rest = url .strip_prefix("https://") - .ok_or_else(|| format!("Official URL must use https: {url}"))?; + .ok_or_else(|| format!("官方 URL 必须使用 https:{url}"))?; let (host, path) = rest .split_once('/') - .ok_or_else(|| format!("Official URL has no path: {url}"))?; + .ok_or_else(|| format!("官方 URL 缺少路径:{url}"))?; let mut destination = PathBuf::from(sanitize_segment(host)); for segment in path.split('/') { @@ -1029,7 +1081,7 @@ impl OfficialResourcePullService { continue; } if segment == "." || segment == ".." { - return Err(format!("Official URL has unsafe path segment: {url}")); + return Err(format!("官方 URL 包含不安全路径片段:{url}")); } let segment = segment.split(['?', '#']).next().unwrap_or(segment); @@ -1060,7 +1112,7 @@ pub fn verify_official_seed_catalog_hash( if actual != expected { return Err(format!( - "official hash mismatch for {data_url}: expected {expected} from {hash_url}, actual {actual}" + "官方 hash 校验失败 {data_url}:期望 {expected}(来自 {hash_url}),实际 {actual}" )); } @@ -1097,6 +1149,28 @@ fn official_seed_hash_pairs(plan: &OfficialResourcePullPlan) -> Vec Vec { + manifest + .entries + .keys() + .filter_map(|data_url| { + let hash_url = data_url + .strip_suffix(".bytes") + .map(|prefix| format!("{prefix}.hash"))?; + if !manifest.entries.contains_key(&hash_url) { + return None; + } + + Some(OfficialSeedHashPair { + data_url: data_url.clone(), + hash_url, + }) + }) + .collect() +} + fn official_hash_refresh_urls(pairs: &[OfficialSeedHashPair]) -> HashSet { let mut urls = HashSet::new(); for pair in pairs { @@ -1166,12 +1240,9 @@ struct PullOneResult { fn file_len_if_exists(path: &Path) -> Result, String> { match fs::metadata(path) { Ok(metadata) if metadata.is_file() => Ok(Some(metadata.len())), - Ok(_) => Err(format!( - "Expected file path but found non-file: {}", - path.display() - )), + Ok(_) => Err(format!("期望文件路径,但实际不是文件:{}", path.display())), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(format!("Failed to inspect {}: {error}", path.display())), + Err(error) => Err(format!("读取路径信息失败 {}:{error}", path.display())), } } @@ -1183,14 +1254,14 @@ fn partial_path_for(destination: &Path) -> PathBuf { fn blake3_file_hex(path: &Path) -> Result { let mut file = - File::open(path).map_err(|error| format!("Failed to open {}: {error}", path.display()))?; + File::open(path).map_err(|error| format!("打开文件失败 {}:{error}", path.display()))?; let mut hasher = blake3::Hasher::new(); let mut buffer = [0u8; 64 * 1024]; loop { let read = file .read(&mut buffer) - .map_err(|error| format!("Failed to read {}: {error}", path.display()))?; + .map_err(|error| format!("读取文件失败 {}:{error}", path.display()))?; if read == 0 { break; } @@ -1202,11 +1273,10 @@ fn blake3_file_hex(path: &Path) -> Result { fn parse_official_xxhash32_decimal(hash_url: &str, bytes: &[u8]) -> Result { let text = std::str::from_utf8(bytes) - .map_err(|error| format!("Official hash sidecar is not UTF-8 at {hash_url}: {error}"))? + .map_err(|error| format!("官方 hash sidecar 不是 UTF-8:{hash_url}:{error}"))? .trim(); - text.parse::().map_err(|error| { - format!("Official hash sidecar is not decimal xxHash32 at {hash_url}: {error}") - }) + text.parse::() + .map_err(|error| format!("官方 hash sidecar 不是十进制 xxHash32:{hash_url}:{error}")) } fn xxhash32(bytes: &[u8]) -> u32 { @@ -1649,6 +1719,37 @@ fi assert!(service.audit_local_manifest(&plan).unwrap().is_clean()); } + #[test] + fn full_local_verification_checks_manifest_and_official_hash_pairs() { + let out_dir = TempDir::new().unwrap(); + let bin_dir = TempDir::new().unwrap(); + let curl_path = bin_dir.path().join("curl"); + write_fake_curl(&curl_path); + + let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path); + let plan = build_official_pull_plan(discovery_plan(), inventory()); + service.pull(&plan).unwrap(); + + let verification = service.verify_local_download_manifest().unwrap(); + assert!(verification.is_clean()); + assert_eq!(verification.items.len(), 7); + assert_eq!(verification.verified_count(), 7); + assert_eq!(verification.official_hash_pair_count, 1); + assert_eq!(verification.official_hash_verified_count, 1); + + let data_path = service + .destination_for_url( + "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes", + ) + .unwrap(); + fs::write(data_path, b"corrupted").unwrap(); + + let corrupted = service.verify_local_download_manifest().unwrap(); + assert!(!corrupted.is_clean()); + assert_eq!(corrupted.failure_count(), 1); + assert_eq!(corrupted.official_hash_errors.len(), 1); + } + #[test] fn refreshes_official_hash_pairs_and_skips_manifest_verified_content() { let out_dir = TempDir::new().unwrap(); @@ -1833,7 +1934,7 @@ fi }; let error = service.pull(&plan).unwrap_err(); - assert!(error.contains("official hash mismatch")); + assert!(error.contains("官方 hash 校验失败")); let manifest = service.read_download_manifest().unwrap(); assert!(manifest.entries.is_empty()); diff --git a/infrastructure/src/official_game_main_config.rs b/infrastructure/src/official_game_main_config.rs index 7a2287e..50f59f7 100644 --- a/infrastructure/src/official_game_main_config.rs +++ b/infrastructure/src/official_game_main_config.rs @@ -83,10 +83,9 @@ impl OfficialGameMainConfigBootstrapService { .source .clone() .filter(|value| !value.is_empty()) - .ok_or_else(|| "Official launcher remote manifest has no source".to_string())?; + .ok_or_else(|| "官方启动器远端 manifest 缺少 source".to_string())?; let cdn_config = self.launcher.fetch_cdn_config()?; - let temp_dir = TempDir::new() - .map_err(|error| format!("Failed to create temporary directory: {error}"))?; + let temp_dir = TempDir::new().map_err(|error| format!("创建临时目录失败:{error}"))?; let (game_zip_url, resources_assets) = self.fetch_resources_assets( &game_config, &manifest, @@ -121,12 +120,12 @@ impl OfficialGameMainConfigBootstrapService { match self.download_file_once(url, destination) { Ok(()) => return Ok(()), Err(error) => { - last_error = Some(format!("attempt {attempt}/{attempts}: {error}")); + last_error = Some(format!("第 {attempt}/{attempts} 次尝试失败:{error}")); } } } - Err(last_error.unwrap_or_else(|| format!("curl failed for {url} without an attempt"))) + Err(last_error.unwrap_or_else(|| format!("curl 未执行就已失败:{url}"))) } fn download_file_once(&self, url: &str, destination: &Path) -> Result<(), String> { @@ -142,14 +141,14 @@ impl OfficialGameMainConfigBootstrapService { .output() .map_err(|error| { format!( - "Failed to launch curl command {}: {error}", + "启动 curl 命令失败 {}:{error}", self.curl_command.display() ) })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("curl failed for {url}: {}", stderr.trim())); + return Err(format!("curl 拉取失败 {url}:{}", stderr.trim())); } Ok(()) @@ -168,7 +167,7 @@ impl OfficialGameMainConfigBootstrapService { let backup_url = launcher_package_url(backup_cdn_root, relative_path)?; self.download_file(&backup_url, destination).map_err(|backup_error| { format!( - "failed to download official game ZIP from primary ({primary_error}) and backup ({backup_error})" + "下载官方游戏资源失败:主地址失败({primary_error}),备用地址也失败({backup_error})" ) }) } @@ -195,11 +194,10 @@ impl OfficialGameMainConfigBootstrapService { )?; let extract_root = temp_root.join("extract"); fs::create_dir_all(&extract_root) - .map_err(|error| format!("Failed to create extract root: {error}"))?; + .map_err(|error| format!("创建解压目录失败:{error}"))?; self.extract_archive(&archive_path, &extract_root)?; - let resources_assets = find_resources_assets(&extract_root).ok_or_else(|| { - "resources.assets not found in official launcher package".to_string() - })?; + let resources_assets = find_resources_assets(&extract_root) + .ok_or_else(|| "官方启动器包内没有找到 resources.assets".to_string())?; Ok((game_zip_url, resources_assets)) } GameMainConfigSource::ManifestFile { source_dir, file } => { @@ -228,7 +226,7 @@ impl OfficialGameMainConfigBootstrapService { .output() .map_err(|error| { format!( - "Failed to launch unzip command {}: {error}", + "启动 unzip 命令失败 {}:{error}", self.unzip_command.display() ) })?; @@ -236,7 +234,7 @@ impl OfficialGameMainConfigBootstrapService { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!( - "unzip failed for {}: {}", + "解压失败 {}:{}", archive_path.display(), stderr.trim() )); @@ -271,7 +269,7 @@ fn select_game_main_config_source<'a>( }); } return Err(format!( - "Official launcher manifest directory source has no BlueArchive_Data/resources.assets entry: {manifest_source}" + "官方启动器 manifest 目录 source 缺少 BlueArchive_Data/resources.assets 条目:{manifest_source}" )); } @@ -282,7 +280,7 @@ fn select_game_main_config_source<'a>( } Err(format!( - "Official launcher metadata has no usable game archive path: source={manifest_source}, game_latest_file_path={}", + "官方启动器元数据没有可用的游戏包路径:source={manifest_source}, game_latest_file_path={}", game_config.game_latest_file_path )) } @@ -318,9 +316,9 @@ fn launcher_manifest_file_relative_path( file_path: &str, ) -> Result { let source = normalize_manifest_source_dir(source_dir) - .ok_or_else(|| format!("Invalid official launcher manifest source: {source_dir}"))?; + .ok_or_else(|| format!("官方启动器 manifest source 无效:{source_dir}"))?; let file = normalize_manifest_file_path(file_path) - .ok_or_else(|| format!("Invalid official launcher manifest file path: {file_path}"))?; + .ok_or_else(|| format!("官方启动器 manifest 文件路径无效:{file_path}"))?; Ok(format!("{source}/{file}")) } @@ -358,18 +356,16 @@ fn verify_manifest_file_size( path: &Path, file: &YostarJpLauncherManifestFile, ) -> Result<(), String> { - let expected_size = file.size.parse::().map_err(|error| { - format!( - "Official launcher manifest has invalid size for {}: {error}", - file.path - ) - })?; + let expected_size = file + .size + .parse::() + .map_err(|error| format!("官方启动器 manifest 中 {} 的 size 无效:{error}", file.path))?; let actual_size = fs::metadata(path) - .map_err(|error| format!("Failed to stat downloaded {}: {error}", path.display()))? + .map_err(|error| format!("读取已下载文件信息失败 {}:{error}", path.display()))? .len(); if actual_size != expected_size { return Err(format!( - "Downloaded official launcher file size mismatch for {}: expected {}, got {}", + "已下载官方启动器文件大小不匹配 {}:期望 {},实际 {}", file.path, expected_size, actual_size )); } diff --git a/infrastructure/src/official_launcher.rs b/infrastructure/src/official_launcher.rs index 0ab20c8..eb39063 100644 --- a/infrastructure/src/official_launcher.rs +++ b/infrastructure/src/official_launcher.rs @@ -109,13 +109,11 @@ impl OfficialLauncherBootstrapService { /// Fetches latest official PC game config. pub fn fetch_game_config(&self) -> Result { let envelope = self.fetch_launcher_envelope("/api/launcher/game/config")?; - let config: YostarJpLauncherGameConfig = - serde_json::from_value(envelope.data).map_err(|error| { - format!("Failed to parse official launcher game config data: {error}") - })?; + let config: YostarJpLauncherGameConfig = serde_json::from_value(envelope.data) + .map_err(|error| format!("解析官方启动器 game config 数据失败:{error}"))?; if config.game_latest_version.is_empty() || config.game_latest_file_path.is_empty() { - return Err("Official launcher game config is missing latest version or basis".into()); + return Err("官方启动器 game config 缺少最新版本或基准路径".into()); } Ok(config) @@ -125,7 +123,7 @@ impl OfficialLauncherBootstrapService { pub fn fetch_cdn_config(&self) -> Result { let envelope = self.fetch_launcher_envelope("/api/launcher/advanced/game/download/cdn")?; serde_json::from_value(envelope.data) - .map_err(|error| format!("Failed to parse official launcher CDN config data: {error}")) + .map_err(|error| format!("解析官方启动器 CDN config 数据失败:{error}")) } /// Fetches the official remote manifest URL for a PC client version and @@ -136,7 +134,7 @@ impl OfficialLauncherBootstrapService { file_path: &str, ) -> Result { if version.is_empty() || file_path.is_empty() { - return Err("Launcher manifest version and file path must not be empty".into()); + return Err("启动器 manifest 版本和文件路径不能为空".into()); } let path = format!( @@ -146,13 +144,11 @@ impl OfficialLauncherBootstrapService { ); let envelope = self.fetch_launcher_envelope(&path)?; let manifest_url: YostarJpLauncherManifestUrl = serde_json::from_value(envelope.data) - .map_err(|error| { - format!("Failed to parse official launcher manifest URL data: {error}") - })?; + .map_err(|error| format!("解析官方启动器 manifest URL 数据失败:{error}"))?; if !is_official_launcher_package_url(&manifest_url.url) { return Err(format!( - "Official launcher API returned non-official manifest URL: {}", + "官方启动器 API 返回了非官方 manifest URL:{}", manifest_url.url )); } @@ -166,9 +162,7 @@ impl OfficialLauncherBootstrapService { manifest_url: &str, ) -> Result { if !is_official_launcher_package_url(manifest_url) { - return Err(format!( - "Refusing to fetch non-official launcher manifest URL: {manifest_url}" - )); + return Err(format!("拒绝拉取非官方启动器 manifest URL:{manifest_url}")); } let output = self.run_curl_with_retry( @@ -186,16 +180,16 @@ impl OfficialLauncherBootstrapService { |output| { let stderr = String::from_utf8_lossy(&output.stderr); format!( - "curl failed for launcher manifest {manifest_url}: {}", + "curl 拉取启动器 manifest 失败 {manifest_url}:{}", stderr.trim() ) }, )?; let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout) - .map_err(|error| format!("Failed to parse official launcher manifest: {error}"))?; + .map_err(|error| format!("解析官方启动器 manifest 失败:{error}"))?; if manifest.files.is_empty() { - return Err("Official launcher remote manifest has no files".into()); + return Err("官方启动器远端 manifest 没有文件条目".into()); } Ok(manifest) @@ -246,22 +240,22 @@ impl OfficialLauncherBootstrapService { }, |output| { let stderr = String::from_utf8_lossy(&output.stderr); - format!("curl failed for {url}: {}", stderr.trim()) + format!("curl 拉取失败 {url}:{}", stderr.trim()) }, )?; let envelope: LauncherEnvelope = serde_json::from_slice(&output.stdout) - .map_err(|error| format!("Failed to parse official launcher API response: {error}"))?; + .map_err(|error| format!("解析官方启动器 API 响应失败:{error}"))?; if envelope.code != 200 { return Err(format!( - "Official launcher API returned code {}: {}", + "官方启动器 API 返回错误码 {}:{}", envelope.code, envelope .message .as_deref() .or(envelope.msg.as_deref()) - .unwrap_or("") + .unwrap_or("<无消息>") )); } @@ -280,20 +274,20 @@ impl OfficialLauncherBootstrapService { Ok(output) if output.status.success() => return Ok(output), Ok(output) => { last_error = Some(format!( - "attempt {attempt}/{attempts}: {}", + "第 {attempt}/{attempts} 次尝试失败:{}", failure_message(&output) )); } Err(error) => { last_error = Some(format!( - "attempt {attempt}/{attempts}: Failed to launch curl command {}: {error}", + "第 {attempt}/{attempts} 次尝试失败:启动 curl 命令失败 {}:{error}", self.curl_command )); } } } - Err(last_error.unwrap_or_else(|| format!("curl command {} did not run", self.curl_command))) + Err(last_error.unwrap_or_else(|| format!("curl 命令未执行:{}", self.curl_command))) } } @@ -315,7 +309,7 @@ pub struct LauncherEnvelope { /// Builds an official launcher API URL from a path. pub fn launcher_api_url(path: &str) -> Result { if !path.starts_with('/') || path.contains("..") || path.contains('\\') { - return Err(format!("Invalid official launcher API path: {path}")); + return Err(format!("官方启动器 API 路径无效:{path}")); } Ok(format!("{YOSTAR_JP_LAUNCHER_API_ROOT}{path}")) @@ -337,7 +331,7 @@ pub fn is_official_launcher_package_url(url: &str) -> bool { /// relative package path. pub fn launcher_package_url(cdn_root: &str, file_path: &str) -> Result { if !is_official_launcher_package_url(cdn_root) { - return Err(format!("Launcher CDN root is not official: {cdn_root}")); + return Err(format!("启动器 CDN 根地址不是官方地址:{cdn_root}")); } validate_launcher_package_path(file_path)?; @@ -368,15 +362,15 @@ fn normalize_relative_path(path: &str) -> String { fn validate_launcher_package_path(path: &str) -> Result<(), String> { if path.is_empty() || path.starts_with('/') || path.starts_with('\\') { - return Err(format!("Invalid launcher package path: {path}")); + return Err(format!("启动器包路径无效:{path}")); } for segment in path.split('/') { if segment.is_empty() || segment == "." || segment == ".." { - return Err(format!("Invalid launcher package path: {path}")); + return Err(format!("启动器包路径无效:{path}")); } if segment.contains('\\') || segment.contains('?') || segment.contains('#') { - return Err(format!("Invalid launcher package path: {path}")); + return Err(format!("启动器包路径无效:{path}")); } } @@ -394,14 +388,14 @@ pub fn launcher_authorization_header( timestamp: Option, ) -> Result { if launcher_version.is_empty() { - return Err("Launcher version must not be empty".into()); + return Err("启动器版本不能为空".into()); } let timestamp = match timestamp { Some(timestamp) => timestamp, None => SystemTime::now() .duration_since(UNIX_EPOCH) - .map_err(|error| format!("System time is before Unix epoch: {error}"))? + .map_err(|error| format!("系统时间早于 Unix epoch:{error}"))? .as_secs(), }; @@ -411,7 +405,7 @@ pub fn launcher_authorization_header( "version": launcher_version, }); let head_json = serde_json::to_string(&head) - .map_err(|error| format!("Failed to serialize launcher auth head: {error}"))?; + .map_err(|error| format!("序列化启动器认证 head 失败:{error}"))?; let mut hasher = Md5::new(); hasher.update(head_json.as_bytes()); hasher.update(body.as_bytes()); @@ -422,7 +416,7 @@ pub fn launcher_authorization_header( "head": head, "sign": sign, })) - .map_err(|error| format!("Failed to serialize launcher auth header: {error}")) + .map_err(|error| format!("序列化启动器认证 header 失败:{error}")) } #[cfg(test)] diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index 67d7d7f..12b8888 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -87,7 +87,7 @@ impl Default for OfficialUpdateConfig { launcher_version: "1.7.2".to_string(), auto_discover: false, platforms: None, - output_root: PathBuf::from("official_update_output"), + output_root: PathBuf::from("./bat-resources"), snapshot_path: None, curl_command: PathBuf::from("curl"), unzip_command: PathBuf::from("unzip"), @@ -442,7 +442,7 @@ impl OfficialUpdateService { progress(OfficialUpdateProgress::new( "start", format!( - "starting official resource sync: output={} dry_run={} auto_discover={}", + "开始官方资源同步:资源目录={} 试运行={} 自动发现={}", config.output_root.display(), config.dry_run, config.auto_discover @@ -451,18 +451,15 @@ impl OfficialUpdateService { check_shutdown_requested(&mut should_cancel)?; let _lock = if config.dry_run { - progress(OfficialUpdateProgress::new( - "lock", - "dry-run: skipping state lock", - )); + progress(OfficialUpdateProgress::new("lock", "试运行:跳过状态锁")); None } else { progress(OfficialUpdateProgress::new( "lock", - format!("acquiring state lock {}", config.lock_path().display()), + format!("获取资源目录状态锁 {}", config.lock_path().display()), )); let lock = OfficialUpdateLock::acquire(config)?; - progress(OfficialUpdateProgress::new("lock", "state lock acquired")); + progress(OfficialUpdateProgress::new("lock", "资源目录状态锁已获取")); Some(lock) }; check_shutdown_requested(&mut should_cancel)?; @@ -483,7 +480,7 @@ impl OfficialUpdateService { progress(OfficialUpdateProgress::new( "bootstrap", format!( - "auto-discover enabled; launcher_version={} cache={}", + "启用自动发现;启动器版本={} 缓存={}", config.launcher_version, bootstrap_cache_path.display() ), @@ -500,7 +497,7 @@ impl OfficialUpdateService { } else { progress(OfficialUpdateProgress::new( "bootstrap", - "auto-discover disabled; using explicit metadata inputs", + "未启用自动发现;使用显式元数据输入", )); None }; @@ -515,7 +512,7 @@ impl OfficialUpdateService { .map(|bootstrap| bootstrap.launcher_metadata.game_latest_version.clone()) }) .ok_or_else(|| { - anyhow::anyhow!("missing app version; pass it explicitly or enable auto-discover") + anyhow::anyhow!("缺少应用版本;请显式传入 --app-version 或启用 --auto-discover") })?; let connection_group = config .connection_group @@ -526,14 +523,12 @@ impl OfficialUpdateService { }) }) .ok_or_else(|| { - anyhow::anyhow!( - "missing connection group; pass it explicitly or enable auto-discover" - ) + anyhow::anyhow!("缺少连接组;请显式传入 --connection-group 或启用 --auto-discover") })?; progress(OfficialUpdateProgress::new( "metadata", format!( - "using app_version={} connection_group={} platforms={}", + "使用应用版本={} 连接组={} 平台={}", app_version, connection_group, platforms_label(platforms) @@ -543,35 +538,30 @@ impl OfficialUpdateService { let server_info_bytes = if let Some(source) = config.server_info_source.as_ref() { progress(OfficialUpdateProgress::new( "server-info", - format!( - "loading server-info from {}", - server_info_source_label(source) - ), + format!("从 {} 读取服务器信息", server_info_source_label(source)), )); load_server_info(source, &fetcher)? } else { let bootstrap = bootstrap.as_ref().ok_or_else(|| { anyhow::anyhow!( - "missing server-info source; pass one explicitly or enable auto-discover" + "缺少服务器信息来源;请显式传入 --server-info-* 或启用 --auto-discover" ) })?; let url = bootstrap .game_main_config .server_info_data_url .as_deref() - .ok_or_else(|| { - anyhow::anyhow!("official GameMainConfig has no ServerInfoDataUrl") - })?; + .ok_or_else(|| anyhow::anyhow!("官方 GameMainConfig 中没有 ServerInfoDataUrl"))?; progress(OfficialUpdateProgress::new( "server-info", - format!("fetching discovered server-info {url}"), + format!("拉取自动发现的服务器信息 {url}"), )); fetcher.fetch_bytes(url).map_err(anyhow::Error::msg)? }; progress(OfficialUpdateProgress::new( "server-info", - format!("parsing server-info bytes={}", server_info_bytes.len()), + format!("解析服务器信息,字节数={}", server_info_bytes.len()), )); check_shutdown_requested(&mut should_cancel)?; let server_info = YostarJpServerInfo::from_slice(&server_info_bytes)?; @@ -581,7 +571,7 @@ impl OfficialUpdateService { progress(OfficialUpdateProgress::new( "discovery", format!( - "resolved addressables_root={} endpoints={}", + "解析到 Addressables 根={} endpoint 数={}", current_snapshot.addressables_root, current_snapshot.endpoints.len() ), @@ -589,7 +579,7 @@ impl OfficialUpdateService { progress(OfficialUpdateProgress::new( "markers", format!( - "checking {} remote marker endpoints", + "检查 {} 个远端标记 endpoint", marker_endpoint_count(¤t_snapshot) ), )); @@ -607,7 +597,7 @@ impl OfficialUpdateService { check_shutdown_requested(&mut should_cancel)?; progress(OfficialUpdateProgress::new( "snapshot", - format!("reading previous snapshot {}", snapshot_path.display()), + format!("读取上次快照 {}", snapshot_path.display()), )); let previous_snapshot = read_snapshot(&snapshot_path)?; let previous_base_snapshot = previous_snapshot @@ -623,13 +613,13 @@ impl OfficialUpdateService { progress(OfficialUpdateProgress::new( "decision", format!( - "remote decision={:?} force={} remote_should_download={}", + "远端决策={:?} 强制刷新={} 远端需要下载={}", sync_plan.decision, config.force, remote_should_download ), )); progress(OfficialUpdateProgress::new( "plan", - "building official pull plan from latest seed catalogs", + "根据最新种子目录构建官方拉取计划", )); let pull_plan = build_pull_plan( &server_info, @@ -643,7 +633,7 @@ impl OfficialUpdateService { let url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len(); progress(OfficialUpdateProgress::new( "plan", - format!("pull plan contains {url_count} official URLs"), + format!("拉取计划包含 {url_count} 个官方 URL"), )); let local_state = fetcher .local_resource_state(&pull_plan) @@ -652,7 +642,7 @@ impl OfficialUpdateService { progress(OfficialUpdateProgress::new( "local-state", format!( - "manifest_entries={} existing_files={} has_local_resources={}", + "manifest 条目={} 已存在文件={} 是否已有本地资源={}", local_state.manifest_entry_count, local_state.existing_file_count, has_local_resources @@ -663,7 +653,7 @@ impl OfficialUpdateService { progress(OfficialUpdateProgress::new( "audit", format!( - "auditing local download manifest {}", + "审计本地下载 manifest {}", fetcher.download_manifest_path().display() ), )); @@ -675,13 +665,13 @@ impl OfficialUpdateService { } else if config.audit_local { progress(OfficialUpdateProgress::new( "audit", - "no local resources found; skipping local audit before first full pull", + "未发现本地资源;首次全量拉取前跳过本地审计", )); None } else { progress(OfficialUpdateProgress::new( "audit", - "local manifest audit disabled", + "本地 manifest 审计已关闭", )); None }; @@ -703,14 +693,14 @@ impl OfficialUpdateService { progress(OfficialUpdateProgress::new( "audit", format!( - "local manifest verified={} repair_needed={}", + "本地 manifest 已校验={} 需要修复={}", local_manifest_verified_count, local_manifest_repair_needed_count ), )); progress(OfficialUpdateProgress::new( "decision", format!( - "final should_download={} repair_needed={} initial_pull_needed={}", + "最终决策 需要下载={} 需要修复={} 首次拉取={}", should_download, repair_needed, initial_pull_needed ), )); @@ -760,7 +750,7 @@ impl OfficialUpdateService { if !should_download { progress(OfficialUpdateProgress::new( "finish", - "up to date; no download needed", + "资源已是最新;无需下载", )); return Ok(report); } @@ -773,20 +763,20 @@ impl OfficialUpdateService { progress(OfficialUpdateProgress::new( "dry-run", format!( - "dry-run plan generated {} URLs", + "试运行已生成 {} 个 URL", report.download_url_count.unwrap_or(0) ), )); } else { progress(OfficialUpdateProgress::new( "dry-run", - "dry-run detected pending download; full URL plan disabled", + "试运行检测到需要下载;未启用完整 URL 计划输出", )); } report.update_status = OfficialUpdateStatus::WouldDownload; progress(OfficialUpdateProgress::new( "finish", - "dry-run finished without writing state", + "试运行完成,未写入状态", )); return Ok(report); } @@ -795,7 +785,7 @@ impl OfficialUpdateService { let download_url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len(); progress(OfficialUpdateProgress::new( "download", - format!("downloading or reusing {download_url_count} official URLs"), + format!("下载或复用 {download_url_count} 个官方 URL"), )); let pull_report = fetcher .pull_with_progress_and_cancellation( @@ -809,7 +799,7 @@ impl OfficialUpdateService { progress(OfficialUpdateProgress::new( "download", format!( - "download phase finished: downloaded={} resumed={} skipped={} transferred_bytes={}", + "下载阶段完成:已下载={} 已续传={} 已复用={} 本轮传输字节={}", pull_report.downloaded_count(), pull_report.resumed_count(), pull_report.skipped_count(), @@ -818,7 +808,7 @@ impl OfficialUpdateService { )); progress(OfficialUpdateProgress::new( "audit", - "running final local manifest audit", + "执行最终本地 manifest 审计", )); check_shutdown_requested(&mut should_cancel)?; let final_audit = fetcher @@ -826,7 +816,7 @@ impl OfficialUpdateService { .map_err(anyhow::Error::msg)?; progress(OfficialUpdateProgress::new( "snapshot", - format!("writing snapshot {}", snapshot_path.display()), + format!("写入快照 {}", snapshot_path.display()), )); write_snapshot(&snapshot_path, ¤t_update_snapshot)?; @@ -845,7 +835,7 @@ impl OfficialUpdateService { progress(OfficialUpdateProgress::new( "finish", format!( - "sync finished: resources={} official_hashes_verified={}", + "同步完成:资源数={} 官方 hash 已校验={}", report.resource_count.unwrap_or(0), report.official_seed_hash_verified_count ), @@ -870,7 +860,7 @@ fn build_pull_plan( progress(OfficialUpdateProgress::new( "plan", format!( - "discovery plan resolved: connection_group={} app_version={} endpoints={}", + "发现计划已解析:连接组={} 应用版本={} endpoint 数={}", discovery.connection_group_name, discovery.app_version, discovery.endpoints.len() @@ -881,7 +871,7 @@ fn build_pull_plan( check_shutdown_requested(should_cancel)?; progress(OfficialUpdateProgress::new( "inventory", - "parsing seed catalogs into download inventory", + "正在把种子目录解析为下载清单", )); let inventory = build_inventory_from_seed_catalogs(&seed_catalogs, platforms)?; Ok(build_official_pull_plan_for_platform_inventory( @@ -921,7 +911,7 @@ fn collect_endpoint_markers( progress(OfficialUpdateProgress::new( "marker", format!( - "fetching {} marker{} {}", + "拉取 {} 标记{} {}", endpoint_kind_label(endpoint.kind), platform_suffix(endpoint.platform), endpoint.url @@ -946,9 +936,7 @@ fn collect_endpoint_markers( fn check_shutdown_requested(should_cancel: &mut dyn FnMut() -> bool) -> anyhow::Result<()> { if should_cancel() { - return Err(anyhow::anyhow!( - "official update interrupted by shutdown request" - )); + return Err(anyhow::anyhow!("官方资源更新已被停止请求中断")); } Ok(()) @@ -978,11 +966,11 @@ fn marker_endpoint_count(snapshot: &YostarJpSyncSnapshot) -> usize { fn server_info_source_label(source: &OfficialServerInfoSource) -> String { match source { - OfficialServerInfoSource::LocalPath(path) => format!("local path {}", path.display()), + OfficialServerInfoSource::LocalPath(path) => format!("本地路径 {}", path.display()), OfficialServerInfoSource::OfficialFile(file_name) => { - format!("official file {file_name}") + format!("官方文件 {file_name}") } - OfficialServerInfoSource::OfficialUrl(url) => format!("official URL {url}"), + OfficialServerInfoSource::OfficialUrl(url) => format!("官方 URL {url}"), } } @@ -1017,17 +1005,14 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda match event.kind { OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new( "download", - format!("({}/{}) started {}", event.index, event.total, event.url), + format!("({}/{}) 开始 {}", event.index, event.total, event.url), ), OfficialResourcePullProgressKind::Finished => { - let status = event - .status - .map(|status| status.as_str()) - .unwrap_or("unknown"); + let status = event.status.map(localized_pull_status).unwrap_or("未知"); OfficialUpdateProgress::new( "download", format!( - "({}/{}) finished status={} bytes={} transferred_bytes={} {}", + "({}/{}) 完成 状态={} 文件字节={} 本轮传输字节={} {}", event.index, event.total, status, @@ -1040,6 +1025,14 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda } } +fn localized_pull_status(status: crate::OfficialResourcePullStatus) -> &'static str { + match status { + crate::OfficialResourcePullStatus::SkippedExisting => "已复用", + crate::OfficialResourcePullStatus::Resumed => "已续传", + crate::OfficialResourcePullStatus::Downloaded => "已下载", + } +} + /// Computes the extended snapshot delta. pub fn diff_extended_snapshot( current: &OfficialUpdateSnapshot, @@ -1074,7 +1067,7 @@ pub fn read_snapshot(path: &Path) -> anyhow::Result(&data).map_err(|legacy_error| { anyhow::anyhow!( - "failed to parse official update snapshot as v2 ({update_error}) or legacy v1 ({legacy_error})" + "无法解析官方更新快照:v2 解析失败 ({update_error}),legacy v1 解析也失败 ({legacy_error})" ) })?; Ok(Some(OfficialUpdateSnapshot::new(legacy, Vec::new(), None))) @@ -1178,7 +1171,7 @@ fn resolve_bootstrap( ); progress(OfficialUpdateProgress::new( "launcher", - "fetching official launcher game config and remote manifest", + "正在拉取官方启动器游戏配置和远端 manifest", )); let (game_config, manifest_url, manifest) = launcher .fetch_latest_remote_manifest() @@ -1189,14 +1182,14 @@ fn resolve_bootstrap( progress(OfficialUpdateProgress::new( "launcher", format!( - "launcher metadata resolved: latest_version={} manifest_files={}", + "启动器元数据已解析:最新版本={} manifest 文件数={}", launcher_metadata.game_latest_version, launcher_metadata.manifest_file_count ), )); progress(OfficialUpdateProgress::new( "bootstrap-cache", - format!("checking bootstrap cache {}", cache_path.display()), + format!("检查启动缓存 {}", cache_path.display()), )); if let Some(cache) = read_bootstrap_cache(cache_path)? { if let Some(game_main_config) = @@ -1204,7 +1197,7 @@ fn resolve_bootstrap( { progress(OfficialUpdateProgress::new( "bootstrap-cache", - "cache hit; reusing parsed GameMainConfig", + "命中缓存;复用已解析的 GameMainConfig", )); return Ok(ResolvedBootstrap { launcher_metadata, @@ -1216,7 +1209,7 @@ fn resolve_bootstrap( progress(OfficialUpdateProgress::new( "game-main-config", - "cache miss; fetching resources.assets and parsing GameMainConfig", + "缓存未命中;拉取 resources.assets 并解析 GameMainConfig", )); check_shutdown_requested(should_cancel)?; let bootstrapper = OfficialGameMainConfigBootstrapService::with_commands( @@ -1249,12 +1242,12 @@ fn resolve_bootstrap( )?; progress(OfficialUpdateProgress::new( "bootstrap-cache", - format!("bootstrap cache written {}", cache_path.display()), + format!("启动缓存已写入 {}", cache_path.display()), )); } else { progress(OfficialUpdateProgress::new( "bootstrap-cache", - "dry-run: bootstrap cache not written", + "试运行:不写入启动缓存", )); } @@ -1287,7 +1280,7 @@ fn fetch_seed_catalogs( progress(OfficialUpdateProgress::new( "catalog", format!( - "fetching seed catalog {}{} {}", + "拉取种子目录 {}{} {}", endpoint_kind_label(endpoint.kind), platform_suffix(endpoint.platform), endpoint.url @@ -1310,21 +1303,18 @@ fn build_inventory_from_seed_catalogs( let table_catalog = catalogs .table_catalog .as_deref() - .ok_or_else(|| anyhow::anyhow!("official discovery did not include TableCatalog.bytes"))?; + .ok_or_else(|| anyhow::anyhow!("官方发现结果缺少 TableCatalog.bytes"))?; let mut platform_catalogs = Vec::new(); for platform in platforms { let bundle_packing_info = catalogs.bundle_packing_infos.get(platform).ok_or_else(|| { anyhow::anyhow!( - "official discovery did not include {} BundlePackingInfo.bytes", + "官方发现结果缺少 {} BundlePackingInfo.bytes", platform.as_str() ) })?; let media_catalog = catalogs.media_catalogs.get(platform).ok_or_else(|| { - anyhow::anyhow!( - "official discovery did not include {} MediaCatalog.bytes", - platform.as_str() - ) + anyhow::anyhow!("官方发现结果缺少 {} MediaCatalog.bytes", platform.as_str()) })?; platform_catalogs.push(YostarJpPlatformCatalogInventory::from_catalog_bytes( @@ -1375,7 +1365,7 @@ impl SeedCatalogs { fn required_platform(endpoint: &YostarJpResourceEndpoint) -> anyhow::Result { endpoint.platform.ok_or_else(|| { anyhow::anyhow!( - "discovery endpoint {:?} has no platform: {}", + "发现 endpoint {:?} 缺少平台:{}", endpoint.kind, endpoint.url ) @@ -1403,13 +1393,13 @@ impl OfficialUpdateLock { continue; } return Err(anyhow::anyhow!( - "official update output is locked: {}", + "官方资源目录已被锁定 (locked):{}", path.display() )); } Err(error) => { return Err(anyhow::anyhow!( - "failed to acquire official update lock {}: {error}", + "获取官方资源目录状态锁失败 {}:{error}", path.display() )); } @@ -1417,7 +1407,7 @@ impl OfficialUpdateLock { } Err(anyhow::anyhow!( - "failed to acquire official update lock {}", + "获取官方资源目录状态锁失败 {}", path.display() )) } @@ -1639,7 +1629,7 @@ mod tests { let first = OfficialUpdateLock::acquire(&config).unwrap(); let second = OfficialUpdateLock::acquire(&config).unwrap_err(); - assert!(second.to_string().contains("locked")); + assert!(second.to_string().contains("锁定")); drop(first); assert!(OfficialUpdateLock::acquire(&config).is_ok()); } @@ -1681,7 +1671,7 @@ mod tests { ) .unwrap_err(); - assert!(error.to_string().contains("shutdown request")); + assert!(error.to_string().contains("停止请求")); assert_eq!(checks, 1); } } diff --git a/infrastructure/tests/official_game_main_config_bootstrap.rs b/infrastructure/tests/official_game_main_config_bootstrap.rs index 95acb41..78a02ce 100644 --- a/infrastructure/tests/official_game_main_config_bootstrap.rs +++ b/infrastructure/tests/official_game_main_config_bootstrap.rs @@ -180,9 +180,9 @@ fn official_update_service_emits_human_progress_events() { assert!(events.iter().any(|event| event.stage == "catalog")); assert!(events.iter().any(|event| event.stage == "dry-run")); assert!(events.iter().any(|event| event.stage == "finish")); - assert!(events.iter().any(|event| event - .message - .contains("pull plan contains 19 official URLs"))); + assert!(events + .iter() + .any(|event| event.message.contains("拉取计划包含 19 个官方 URL"))); } #[test] @@ -201,14 +201,14 @@ fn official_update_first_run_pulls_without_pre_audit() { assert_eq!(report.skipped_count, 0); assert_eq!(report.local_manifest_repair_needed_count, 0); assert!(events.iter().any(|event| { - event.stage == "local-state" && event.message.contains("has_local_resources=false") + event.stage == "local-state" && event.message.contains("是否已有本地资源=false") })); assert!(events .iter() - .any(|event| { event.stage == "audit" && event.message.contains("skipping local audit") })); - assert!(events.iter().any(|event| { - event.stage == "decision" && event.message.contains("initial_pull_needed=true") - })); + .any(|event| { event.stage == "audit" && event.message.contains("跳过本地审计") })); + assert!(events + .iter() + .any(|event| { event.stage == "decision" && event.message.contains("首次拉取=true") })); } #[test] @@ -227,13 +227,13 @@ fn official_update_second_run_audits_existing_resources_before_reuse() { assert_eq!(report.local_manifest_verified_count, 19); assert_eq!(report.local_manifest_repair_needed_count, 0); assert!(events.iter().any(|event| { - event.stage == "local-state" && event.message.contains("has_local_resources=true") + event.stage == "local-state" && event.message.contains("是否已有本地资源=true") })); assert!(events.iter().any(|event| { - event.stage == "audit" && event.message.contains("auditing local download manifest") + event.stage == "audit" && event.message.contains("审计本地下载 manifest") })); assert!(events.iter().any(|event| { - event.stage == "decision" && event.message.contains("initial_pull_needed=false") + event.stage == "decision" && event.message.contains("首次拉取=false") })); }