use bat_adapters::official::yostar_jp::PatchPlatform; use bat_infrastructure::{ gc_orphan_staging, lexical_absolute, open_append_file, read_file_no_symlink, read_version_state, redact_proxy_url, resolve_curl_proxy, validate_output_root, validate_runtime_state_dir, write_file_atomic, CurlProxyConfig, CurlProxyMode, OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PRIVATE_FILE_MODE, }; use serde::{Deserialize, Serialize}; use std::env; use std::fs::{self, OpenOptions}; use std::io::{BufRead, BufReader, Write}; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; #[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}; 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_STRUCTURED_LOG_FILE: &str = "bat-events.jsonl"; const DAEMON_SOCKET_FILE: &str = "bat.sock"; const DAEMON_CONTROL_LOCK_FILE: &str = "bat-control.lock"; /// 后台进程保存代理凭据的专用文件,仅供 restart/reload 复用,永不序列化到状态输出。 const DAEMON_PROXY_SECRET_FILE: &str = "bat-proxy.secret"; /// 代理凭据下传子进程使用的环境变量;避免凭据出现在子进程 argv(/proc//cmdline)。 const PROXY_URL_ENV_VAR: &str = "BAT_OFFICIAL_SYNC_PROXY_URL"; /// 后台子进程 argv 中用于标记“代理 URL 从环境变量读取”的内部 flag。 const PROXY_FROM_ENV_FLAG: &str = "--proxy-from-env"; const DAEMON_STATUS_VERSION: u32 = 1; const STRUCTURED_LOG_MAX_BYTES: u64 = 5 * 1024 * 1024; const STRUCTURED_LOG_ROTATE_KEEP: usize = 3; const OFFICIAL_CURRENT_LINK: &str = "current"; 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 ============================================================ "#; fn main() { match run() { Ok(exit_code) if exit_code != 0 => std::process::exit(exit_code), Ok(_) => {} Err(error) => { let error_message = error.to_string(); let exit_code = if is_locked_error(&error_message) { EXIT_LOCKED } else { EXIT_ERROR }; let payload = ErrorReport { status: "error", exit_code, error: localize_error_message(&error_message), next_retry_seconds: None, }; eprintln!( "{}", serde_json::to_string_pretty(&payload) .unwrap_or_else(|_| "{\"status\":\"error\"}".to_string()) ); std::process::exit(exit_code); } } } fn run() -> anyhow::Result { let options = parse_args()?; if matches!( options.command, CliCommand::Run | CliCommand::Refresh | CliCommand::Verify | CliCommand::Repair ) && !options.daemon && options.banner { print_startup_banner(); } match options.command { CliCommand::Run => { if options.daemon { let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; run_daemon_start(options)?; } else if options.watch { if !options.daemon_child { assert_no_live_daemon_output_conflict(&options, "watch")?; } run_watch(options)?; } else { assert_no_live_daemon_output_conflict(&options, "run")?; 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 => { let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; print_daemon_status(&options.state_dir, options.output_format)?; Ok(0) } CliCommand::Stop => { let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; stop_daemon(&options.state_dir, options.output_format)?; Ok(0) } CliCommand::Restart => { let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; run_daemon_restart(&options, "restart")?; Ok(0) } CliCommand::Reload => { let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; 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 => { let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; 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) } } } #[derive(Debug, Serialize)] struct ErrorReport<'a> { status: &'a str, exit_code: i32, error: String, #[serde(skip_serializing_if = "Option::is_none")] next_retry_seconds: Option, } #[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, proxy_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, proxy_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 常驻模式不能和 --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.daemon_child { validate_runtime_state_dir(&options.state_dir).map_err(anyhow::Error::msg)?; } let service = OfficialUpdateService::new(); let mut logger = ProgressLogger::new(options.progress); let daemon_state_dir = options.state_dir.clone(); if options.daemon_child { logger.attach_structured_log(daemon_structured_log_path(&daemon_state_dir)); } 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_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!("已触发固定时间强制刷新:{DAILY_FORCED_REFRESH_LABEL}"), ); next_forced_refresh_at = next_forced_refresh_after(now); } let mut iteration_config = options.config.clone(); 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 && rpc_force_this_round { "开始执行 watch 轮次:本轮为固定时间和 RPC 触发的强制刷新" } else if pending_scheduled_force { "开始执行 watch 轮次:本轮为固定时间强制刷新" } else if rpc_force_this_round { "开始执行 watch 轮次:本轮为 RPC 触发的强制刷新" } else { "开始执行 watch 轮次" }, ); 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, last_success_unix_seconds: None, next_check_unix_seconds: None, pending_scheduled_force, next_forced_refresh_at, }, ); match service.run_with_progress_and_cancellation( &iteration_config, |event| { record_daemon_progress( options.daemon_child, &daemon_state_dir, &mut logger, &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) { 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()), last_success_unix_seconds: Some(unix_seconds_now()), next_check_unix_seconds: Some(unix_seconds_after(sleep_for)), pending_scheduled_force, next_forced_refresh_at, }, ); logger.log_text( "watch", format!( "本轮完成:状态={};下次检查将在 {} 后执行;距离下一次固定强制刷新还有 {}", report.update_status.as_str(), format_duration(sleep_for), format_duration(duration_until(next_forced_refresh_at, SystemTime::now())) ), ); } Err(error) => { 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()), last_success_unix_seconds: None, next_check_unix_seconds: Some(unix_seconds_after(sleep_for)), pending_scheduled_force, next_forced_refresh_at, }, ); logger.log_text( "watch", format!( "本轮失败;将在 {} 后重试:{}", format_duration(sleep_for), localize_error_message(&error.to_string()) ), ); let payload = ErrorReport { status: "error", exit_code: EXIT_ERROR, 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; } } } 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, last_success_unix_seconds: None, next_check_unix_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, #[serde(default)] structured_log_path: Option, started_unix_seconds: u64, updated_unix_seconds: u64, #[serde(default)] last_success_unix_seconds: Option, #[serde(default)] next_check_unix_seconds: Option, last_update_status: Option, last_error: Option, next_retry_seconds: Option, #[serde(default)] current_stage: Option, #[serde(default)] current_message: Option, #[serde(default)] download_progress: Option, pending_scheduled_force: bool, next_forced_refresh_unix_seconds: Option, command: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct DaemonDownloadProgress { index: usize, total: usize, url: String, status: Option, bytes: Option, transferred_bytes: Option, #[serde(default)] failure_kind: Option, #[serde(default)] failure_http_status: Option, #[serde(default)] failure_retryable: Option, #[serde(default)] failure_attempts: Option, #[serde(default)] quarantined: Option, } #[derive(Debug)] struct DaemonStatusUpdate<'a> { state: &'a str, last_update_status: Option, last_error: Option, next_retry_seconds: Option, last_success_unix_seconds: Option, next_check_unix_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) { logger.log_text("daemon", format!("更新后台状态失败:{error}")); } } fn record_daemon_progress( enabled: bool, state_dir: &Path, logger: &mut ProgressLogger, event: &OfficialUpdateProgress, ) { if !enabled { return; } if let Err(error) = update_daemon_progress(state_dir, event) { 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); } } #[derive(Debug)] struct DaemonControlLock { path: PathBuf, pid: u32, } impl DaemonControlLock { fn acquire(state_dir: &Path) -> anyhow::Result { validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?; fs::create_dir_all(state_dir)?; let path = daemon_control_lock_path(state_dir); let pid = std::process::id(); for attempt in 0..=1 { let mut options = OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] options.mode(PRIVATE_FILE_MODE); match options.open(&path) { Ok(mut file) => { file.write_all(pid.to_string().as_bytes())?; return Ok(Self { path, pid }); } Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { if attempt == 0 && remove_recoverable_pid_lock(&path)? { continue; } return Err(anyhow::anyhow!( "后台控制命令已被锁定 (locked):{};{};如确认没有 bat 控制命令正在运行,可执行 clean-stable 清理", path.display(), describe_pid_lock_owner(&path)? )); } Err(error) => { return Err(anyhow::anyhow!( "获取后台控制锁失败 {}:{error}", path.display() )); } } } Err(anyhow::anyhow!("获取后台控制锁失败 {}", path.display())) } } impl Drop for DaemonControlLock { fn drop(&mut self) { let expected = self.pid.to_string(); if fs::symlink_metadata(&self.path) .map(|metadata| metadata.file_type().is_symlink()) .unwrap_or(false) { return; } if fs::read_to_string(&self.path) .map(|contents| contents.trim() == expected) .unwrap_or(false) { let _ = fs::remove_file(&self.path); } } } #[derive(Debug, Clone, PartialEq, Eq)] enum PidLockState { Missing, Active(u32), StalePid(u32), Corrupt, } fn classify_pid_lock_file(path: &Path) -> anyhow::Result { let metadata = match fs::symlink_metadata(path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return Ok(PidLockState::Missing); } Err(error) => return Err(error.into()), }; if metadata.file_type().is_symlink() || !metadata.is_file() { return Ok(PidLockState::Corrupt); } let contents = fs::read_to_string(path)?; let Some(pid) = parse_pid_value(&contents) else { return Ok(PidLockState::Corrupt); }; if process_exists(pid) { Ok(PidLockState::Active(pid)) } else { Ok(PidLockState::StalePid(pid)) } } fn remove_recoverable_pid_lock(path: &Path) -> anyhow::Result { match classify_pid_lock_file(path)? { PidLockState::Missing => Ok(false), PidLockState::Active(_) => Ok(false), PidLockState::StalePid(_) | PidLockState::Corrupt => { fs::remove_file(path)?; Ok(true) } } } fn describe_pid_lock_owner(path: &Path) -> anyhow::Result { Ok(match classify_pid_lock_file(path)? { PidLockState::Missing => "锁文件已不存在".to_string(), PidLockState::Active(pid) => format!("owner_pid={pid} 仍在运行"), PidLockState::StalePid(pid) => format!("owner_pid={pid} 已失效"), PidLockState::Corrupt => "锁文件内容不是有效 PID".to_string(), }) } fn parse_pid_value(contents: &str) -> Option { contents.trim().parse::().ok().filter(|pid| *pid > 0) } #[derive(Debug, Clone, PartialEq, Eq)] struct DaemonResourceConflict { pid: Option, daemon_root: Option, target_root: PathBuf, } fn assert_no_live_daemon_output_conflict( options: &CliOptions, command_name: &str, ) -> anyhow::Result<()> { if options.daemon || options.daemon_child || options.config.dry_run { return Ok(()); } let Some(conflict) = live_daemon_resource_conflict(&options.state_dir, &options.config.output_root)? else { return Ok(()); }; let daemon_root = conflict .daemon_root .as_ref() .map(|path| path.display().to_string()) .unwrap_or_else(|| "未知".to_string()); let pid = conflict .pid .map(|pid| pid.to_string()) .unwrap_or_else(|| "未知".to_string()); Err(anyhow::anyhow!( "后台同步进程正在管理同一资源目录 (locked):command={command_name} pid={pid} daemon_output={} target_output={};请通过后台 refresh/reload 控制刷新,或先执行 stop 再手动写入资源", daemon_root, conflict.target_root.display() )) } fn live_daemon_resource_conflict( state_dir: &Path, output_root: &Path, ) -> anyhow::Result> { let pid_path = daemon_pid_path(state_dir); let status_path = daemon_status_path(state_dir); let pid_from_file = read_pid_file(&pid_path)?; let rpc_available = daemon_rpc_available(state_dir); let pid_file_live = pid_from_file.map(process_exists).unwrap_or(false); let status_file = match read_daemon_status_file(&status_path) { Ok(status_file) => status_file, Err(error) if pid_file_live || rpc_available => { return Err(anyhow::anyhow!( "后台同步进程可能仍在运行,但状态文件不可解析 (locked):{}:{error};请先执行 status/stop 或 clean-stable 恢复状态目录", status_path.display() )); } Err(_) => return Ok(None), }; let status_pid = status_file.as_ref().map(|status| status.pid); let pid = pid_from_file.or(status_pid); let live = pid_file_live || status_pid.map(process_exists).unwrap_or(false) || rpc_available; if !live { return Ok(None); } let target_root = normalized_abs_path(output_root)?; let Some(daemon_root) = status_file .as_ref() .map(|status| status.resource_output_root.clone()) else { return Ok(Some(DaemonResourceConflict { pid, daemon_root: None, target_root, })); }; let normalized_daemon_root = normalized_abs_path(&daemon_root)?; if normalized_daemon_root == target_root { Ok(Some(DaemonResourceConflict { pid, daemon_root: Some(normalized_daemon_root), target_root, })) } else { Ok(None) } } fn normalized_abs_path(path: &Path) -> anyhow::Result { let absolute = if path.is_absolute() { path.to_path_buf() } else { env::current_dir()?.join(path) }; Ok(fs::canonicalize(&absolute).unwrap_or_else(|_| lexically_normalize_path(&absolute))) } fn lexically_normalize_path(path: &Path) -> PathBuf { let mut normalized = PathBuf::new(); for component in path.components() { match component { std::path::Component::CurDir => {} std::path::Component::ParentDir => { if !normalized.pop() { normalized.push(component.as_os_str()); } } _ => normalized.push(component.as_os_str()), } } if normalized.as_os_str().is_empty() { PathBuf::from(".") } else { normalized } } 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 { validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?; fs::create_dir_all(state_dir)?; let socket_path = daemon_socket_path(state_dir); if daemon_socket_path_exists(&socket_path)? { ensure_daemon_socket_not_symlink(&socket_path)?; 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); ensure_daemon_socket_not_symlink(&socket_path)?; 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 { let socket_path = daemon_socket_path(state_dir); ensure_daemon_socket_not_symlink(&socket_path).is_ok() && UnixStream::connect(socket_path).is_ok() } #[cfg(not(unix))] fn daemon_rpc_available(_state_dir: &Path) -> bool { false } #[cfg(unix)] fn ensure_daemon_socket_not_symlink(socket_path: &Path) -> anyhow::Result<()> { match fs::symlink_metadata(socket_path) { Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow::anyhow!( "后台 RPC socket 不能是 symlink:{}", socket_path.display() )), Ok(_) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(error) => Err(error.into()), } } #[cfg(unix)] fn daemon_socket_path_exists(socket_path: &Path) -> anyhow::Result { path_exists_no_follow(socket_path).map_err(|error| { anyhow::anyhow!( "后台 RPC socket 路径检查失败 {}:{error}", socket_path.display() ) }) } #[cfg(not(unix))] fn daemon_socket_path_exists(socket_path: &Path) -> anyhow::Result { path_exists_no_follow(socket_path) } fn path_exists_no_follow(path: &Path) -> anyhow::Result { match fs::symlink_metadata(path) { Ok(_) => Ok(true), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), Err(error) => Err(error.into()), } } #[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, structured_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, stale_pid_file: bool, stale_socket: bool, log_path: Option, structured_log_path: Option, rotated_structured_log_paths: Vec, started_unix_seconds: Option, updated_unix_seconds: Option, last_success_unix_seconds: Option, next_check_unix_seconds: Option, daemon_state: Option, last_update_status: Option, last_error: Option, next_retry_seconds: Option, current_stage: Option, current_message: Option, download_progress: Option, version_state_path: Option, version_state: 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 = normalized_abs_path(&options.config.output_root)?; let state_dir = options.state_dir.clone(); let args = daemon_child_args(options); let proxy_url = curl_proxy_url(&options.config.curl_proxy); start_daemon_with_args( state_dir, resource_output_root, args, proxy_url, "后台同步已启动", ) } fn start_daemon_with_args( state_dir: PathBuf, resource_output_root: PathBuf, args: Vec, proxy_url: Option, message: &'static str, ) -> anyhow::Result { validate_runtime_state_dir(&state_dir).map_err(anyhow::Error::msg)?; 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 structured_log_path = daemon_structured_log_path(&state_dir); let socket_path = daemon_socket_path(&state_dir); match classify_pid_lock_file(&pid_path)? { PidLockState::Active(existing_pid) => { return Err(anyhow::anyhow!( "官方同步后台进程已经在运行,pid={existing_pid}" )); } PidLockState::StalePid(_) | PidLockState::Corrupt => { let _ = fs::remove_file(&pid_path); let _ = fs::remove_file(&socket_path); } PidLockState::Missing => {} } // 代理凭据经环境变量下传子进程(/proc//environ 仅属主可读), // 并写入专用 0600 文件供后续 restart/reload 复用;无代理时清除残留凭据。 match proxy_url.as_deref() { Some(url) => write_daemon_proxy_secret(&state_dir, url)?, None => clear_daemon_proxy_secret(&state_dir), } let executable = env::current_exe()?; let log = open_append_file(&log_path, PRIVATE_FILE_MODE, "后台日志").map_err(anyhow::Error::msg)?; 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)); match proxy_url.as_deref() { Some(url) => { command.env(PROXY_URL_ENV_VAR, url); } None => { command.env_remove(PROXY_URL_ENV_VAR); } } configure_daemon_command(&mut command); let child = match command.spawn() { Ok(child) => child, Err(error) => { // 子进程未能启动,清除刚写入的代理凭据,避免残留。 clear_daemon_proxy_secret(&state_dir); return Err(error.into()); } }; let pid = child.id(); if let Err(error) = write_file_atomic( &pid_path, pid.to_string().as_bytes(), PRIVATE_FILE_MODE, "后台 PID 文件", ) .map_err(anyhow::Error::msg) { let _ = terminate_process(pid); clear_daemon_proxy_secret(&state_dir); return Err(error); } 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(), structured_log_path: Some(structured_log_path.clone()), started_unix_seconds: unix_seconds_now(), updated_unix_seconds: unix_seconds_now(), last_success_unix_seconds: None, next_check_unix_seconds: None, last_update_status: None, last_error: None, next_retry_seconds: None, current_stage: None, current_message: None, download_progress: 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, structured_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 pid_state = classify_pid_lock_file(&pid_path)?; let (pid, running) = match pid_state { PidLockState::Missing => { 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), }); } PidLockState::Corrupt => { let _ = fs::remove_file(&pid_path); let _ = fs::remove_file(daemon_socket_path(state_dir)); return Ok(DaemonStopReport { status: "stale_pid_removed", message: "已清理无效的后台 PID 文件", stopped: false, pid: None, state_dir: state_dir.to_path_buf(), pid_path, socket_path: daemon_socket_path(state_dir), }); } PidLockState::StalePid(pid) => (pid, false), PidLockState::Active(pid) => { terminate_process(pid)?; if !wait_for_process_exit(pid, Duration::from_secs(5)) { return Err(anyhow::anyhow!("后台进程 pid={pid} 在 5 秒内未停止")); } (pid, true) } }; 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_file_state = classify_pid_lock_file(&pid_path)?; let pid_file_pid = match pid_file_state { PidLockState::Active(pid) | PidLockState::StalePid(pid) => Some(pid), PidLockState::Missing | PidLockState::Corrupt => None, }; let pid = pid_file_pid.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); let stale_pid_file = matches!( pid_file_state, PidLockState::StalePid(_) | PidLockState::Corrupt ); let stale_socket = daemon_socket_path_exists(&socket_path)? && !rpc_available; let default_log_path = daemon_log_path(state_dir); let default_log_exists = path_exists_no_follow(&default_log_path)?; let structured_log_path = status_file .as_ref() .and_then(|status| status.structured_log_path.clone()) .unwrap_or_else(|| daemon_structured_log_path(state_dir)); let structured_log_exists = path_exists_no_follow(&structured_log_path)?; let rotated_structured_log_paths = rotated_structured_log_paths(&structured_log_path); let version_state_path = status_file.as_ref().map(|status| { status .resource_output_root .join("official-version-state.json") }); let version_state = version_state_path .as_ref() .and_then(|path| read_version_state(path).ok().flatten()); Ok(DaemonStatusReport { status: if running { "running" } else { "stopped" }, message: if running { "后台进程正在运行" } else if stale_pid_file || stale_socket { "后台进程当前未运行,但检测到失效 PID/socket;可执行 clean-stable 清理" } 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, stale_pid_file, stale_socket, log_path: status_file .as_ref() .map(|status| status.log_path.clone()) .or_else(|| default_log_exists.then_some(default_log_path)), structured_log_path: structured_log_exists.then_some(structured_log_path), rotated_structured_log_paths, 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), last_success_unix_seconds: status_file .as_ref() .and_then(|status| status.last_success_unix_seconds), next_check_unix_seconds: status_file .as_ref() .and_then(|status| status.next_check_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), current_stage: status_file .as_ref() .and_then(|status| status.current_stage.clone()), current_message: status_file .as_ref() .and_then(|status| status.current_message.clone()), download_progress: status_file .as_ref() .and_then(|status| status.download_progress.clone()), version_state_path, version_state, 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| redact_command_proxy_credentials(status.command)), }) } /// Redacts proxy credentials in a saved daemon command before it is surfaced to /// callers (RPC `status`, `bat status --json`, human output). /// /// The on-disk status file keeps the raw command because `restart`/`reload` /// relaunch the daemon from it; only the externally visible copy is redacted so /// credentials never reach stdout, monitoring, or log collection. fn redact_command_proxy_credentials(command: Vec) -> Vec { let mut redacted = command; for index in 0..redacted.len() { if redacted[index] == "--proxy" { if let Some(value) = redacted.get_mut(index + 1) { *value = redact_proxy_url(value); } } } redacted } #[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 || options.proxy_option_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 saved_proxy_url = read_daemon_proxy_secret(&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, proxy_url, strategy) = if has_explicit_options { let mut start_options = options.clone(); start_options.daemon = true; start_options.watch = false; ( options.state_dir.clone(), normalized_abs_path(&options.config.output_root)?, daemon_child_args(&start_options), curl_proxy_url(&options.config.curl_proxy), "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}" )); } // 复用命令若声明代理从环境变量读取,则必须能从凭据文件还原 URL。 let proxy_url = if args.iter().any(|arg| arg == PROXY_FROM_ENV_FLAG) { Some(saved_proxy_url.ok_or_else(|| { anyhow::anyhow!( "后台配置需要代理凭据但 {DAEMON_PROXY_SECRET_FILE} 缺失;请为 {command_name} 重新传入 --proxy" ) })?) } else { None }; ( options.state_dir.clone(), status_file.resource_output_root, args, proxy_url, "restart_with_existing_command", ) }; let start = start_daemon_with_args( state_dir.clone(), resource_output_root.clone(), args, proxy_url, 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 _control_lock = DaemonControlLock::acquire(&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(()); } assert_no_live_daemon_output_conflict(options, command_name)?; 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.proxy_option_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.curl_proxy == defaults.curl_proxy && 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, "stale_pid_file", "失效 PID"); print_json_field(value, "stale_socket", "失效 socket"); print_json_field(value, "last_update_status", "上次同步"); print_json_field(value, "last_success_unix_seconds", "最后成功时间"); print_json_field(value, "last_error", "上次错误"); print_json_field(value, "next_retry_seconds", "下次重试秒数"); print_json_field(value, "next_check_unix_seconds", "下次检查时间"); print_json_field(value, "current_stage", "当前阶段"); print_json_field(value, "current_message", "当前消息"); print_daemon_download_progress_json_summary(value.get("download_progress")); print_json_field(value, "version_state_path", "版本状态路径"); print_daemon_version_state_json_summary(value.get("version_state")); 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", "日志"); print_json_field(value, "structured_log_path", "结构化日志"); print_json_field(value, "rotated_structured_log_paths", "轮转日志"); 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_daemon_download_progress_json_summary(value: Option<&serde_json::Value>) { let Some(value) = value else { return; }; if value.is_null() { return; } print_field( "下载进度", format_daemon_download_progress_json(value) .unwrap_or_else(|error| format!("无法解析:{error}")), ); } fn format_daemon_download_progress_json(value: &serde_json::Value) -> Result { let progress = serde_json::from_value::(value.clone()) .map_err(|error| error.to_string())?; Ok(format_daemon_download_progress(&progress)) } fn print_daemon_version_state_json_summary(value: Option<&serde_json::Value>) { let Some(value) = value else { return; }; if value.is_null() { return; } match serde_json::from_value::(value.clone()) { Ok(version_state) => print_daemon_version_state_summary(&version_state), Err(error) => print_field("版本状态", format!("无法解析:{error}")), } } fn print_daemon_version_state_summary(version_state: &OfficialVersionState) { print_optional_field( "当前完成版本", version_state .current_completed_version .as_ref() .map(|version| version.id.as_str()), ); print_optional_field( "正在拉取版本", version_state .in_progress_version .as_ref() .map(|version| version.id.as_str()), ); print_optional_field( "上一个可用版本", version_state .previous_available_version .as_ref() .map(|version| version.id.as_str()), ); let historical_failures = visible_historical_failed_versions(version_state); print_field("历史失败版本数", historical_failures.len()); if let Some(failed) = historical_failures.last() { print_field("最近历史失败版本", &failed.version.id); print_field("最近历史失败时间", failed.failed_unix_seconds); print_field("最近历史失败原因", &failed.error); } } fn visible_historical_failed_versions( version_state: &OfficialVersionState, ) -> Vec<&OfficialFailedVersionRecord> { let in_progress = version_state.in_progress_version.as_ref(); version_state .failed_versions .iter() .filter(|failed| { !in_progress.is_some_and(|version| version_matches_for_status(&failed.version, version)) }) .collect() } fn version_matches_for_status(left: &OfficialVersionRecord, right: &OfficialVersionRecord) -> bool { left.app_version == right.app_version && left.bundle_version == right.bundle_version && left.addressables_root == right.addressables_root } 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_daemon_download_progress(progress: &DaemonDownloadProgress) -> String { let status = progress.status.as_deref().unwrap_or("running"); if status == "failed" { return format!( "{}/{} failed kind={} http={} retryable={} attempts={} quarantined={} {}", progress.index, progress.total, progress.failure_kind.as_deref().unwrap_or("unknown"), progress .failure_http_status .map(|status| status.to_string()) .unwrap_or_else(|| "none".to_string()), progress .failure_retryable .map(|retryable| retryable.to_string()) .unwrap_or_else(|| "unknown".to_string()), progress .failure_attempts .map(|attempts| attempts.to_string()) .unwrap_or_else(|| "0".to_string()), progress .quarantined .map(|quarantined| quarantined.to_string()) .unwrap_or_else(|| "false".to_string()), progress.url ); } format!( "{}/{} {} {}", progress.index, progress.total, status, progress.url ) } fn print_verification_summary(summary: &OfficialVerificationSummary) { println!(" 校验摘要:"); for line in verification_summary_lines(summary) { println!(" - {line}"); } } fn verification_summary_lines(summary: &OfficialVerificationSummary) -> Vec { vec![ format!( "官方 .hash 强校验: {} 对 ({})", summary.official_hash_verified_count, summary.official_hash_scope ), format!( "本地 BLAKE3 复用校验: {} 项通过, {} 项需修复 ({})", summary.local_manifest_blake3_verified_count, summary.local_manifest_repair_needed_count, summary.local_manifest_blake3_scope ), format!( "ZIP 结构校验: {} 个 ZIP 通过 ({})", summary.zip_structure_verified_count, summary.zip_structure_scope ), ] } 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("输出根目录", &self.output_root); print_path_field("active release", &self.active_resource_root); print_path_field("current", &self.current_path); print_path_field("version state", &self.version_state_path); print_optional_path_field("staging", self.staging_path.as_ref()); print_optional_path_field("published", self.published_version_path.as_ref()); 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_verification_summary(&self.verification_summary); 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); print_path_field("结构化日志", &self.structured_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_field("失效 PID", format_bool(self.stale_pid_file)); print_field("失效 socket", format_bool(self.stale_socket)); print_optional_field("上次同步", self.last_update_status.as_deref()); print_optional_field("最后成功时间", self.last_success_unix_seconds); print_optional_field("上次错误", self.last_error.as_deref()); print_optional_field("下次重试秒数", self.next_retry_seconds); print_optional_field("下次检查时间", self.next_check_unix_seconds); print_optional_field("当前阶段", self.current_stage.as_deref()); print_optional_field("当前消息", self.current_message.as_deref()); if let Some(progress) = self.download_progress.as_ref() { print_field("下载进度", format_daemon_download_progress(progress)); } if let Some(version_state) = self.version_state.as_ref() { print_daemon_version_state_summary(version_state); } print_optional_path_field("资源目录", self.resource_output_root.as_ref()); print_optional_path_field("版本状态", self.version_state_path.as_ref()); print_path_field("状态目录", &self.state_dir); print_path_field("socket", &self.socket_path); print_optional_path_field("日志", self.log_path.as_ref()); print_optional_path_field("结构化日志", self.structured_log_path.as_ref()); let rotated = self .rotated_structured_log_paths .iter() .map(|path| path.display().to_string()) .collect::>(); print_list("轮转日志", &rotated, 5); 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_path_field("校验资源目录", &self.verified_resource_root); 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); print_verification_summary(&self.verification_summary); 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, zip_error: Option, } #[derive(Debug, Serialize)] struct VerifyCommandReport { command: &'static str, status: &'static str, message: &'static str, healthy: bool, remote_update_status: String, verified_resource_root: PathBuf, 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, verification_summary: OfficialVerificationSummary, 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 verified_resource_root = active_official_resource_root(&config.output_root)?; let verification = bat_infrastructure::OfficialResourcePullService::with_curl_command( &verified_resource_root, &config.curl_command, ) .with_proxy_config(config.curl_proxy.clone()) .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(), zip_error: item.zip_error.clone(), }) .collect::>(); let healthy = update_report.update_status == OfficialUpdateStatus::UpToDate && update_report.local_manifest_repair_needed_count == 0 && verification.is_clean(); let verification_summary = OfficialVerificationSummary::new( verification.manifest_blake3_verified_count(), verification.failure_count(), verification.official_hash_verified_count, verification.zip_structure_verified_count(), ); 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(), verified_resource_root, 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, verification_summary, 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 bytes = read_file_no_symlink(&log_path, "后台日志").map_err(anyhow::Error::msg)?; let exists = bytes.is_some(); let bytes = bytes.unwrap_or_default(); 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![ path_check("state_dir", &options.state_dir, "后台状态目录可用"), path_check( "output_root", &options.config.output_root, "资源输出目录可用", ), safety_check( "output_root_safety", validate_output_root(&options.config.output_root), "资源输出目录安全边界通过", ), safety_check( "state_dir_safety", validate_runtime_state_dir(&options.state_dir), "后台状态目录安全边界通过", ), command_check("curl", &options.config.curl_command), proxy_check(&options.config.curl_proxy), 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 = daemon_socket_path_exists(&socket_path).unwrap_or(false); 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(); checks.push(match classify_pid_lock_file(&lock_path)? { PidLockState::Missing => DoctorCheck { name: "resource_lock", ok: true, message: "资源目录没有活动锁".to_string(), }, PidLockState::Active(pid) => DoctorCheck { name: "resource_lock", ok: false, message: format!( "资源目录锁正在使用:{},owner_pid={pid}", lock_path.display() ), }, PidLockState::StalePid(pid) => DoctorCheck { name: "resource_lock", ok: false, message: format!( "发现失效资源锁:{},owner_pid={pid};可执行 clean-stable 清理", lock_path.display() ), }, PidLockState::Corrupt => DoctorCheck { name: "resource_lock", ok: false, message: format!( "发现损坏资源锁:{};可执行 clean-stable 清理", lock_path.display() ), }, }); let control_lock_path = daemon_control_lock_path(&options.state_dir); checks.push(match classify_pid_lock_file(&control_lock_path)? { PidLockState::Missing => DoctorCheck { name: "daemon_control_lock", ok: true, message: "后台控制锁没有活动锁".to_string(), }, PidLockState::Active(pid) => DoctorCheck { name: "daemon_control_lock", ok: true, message: format!( "后台控制命令正在运行:{},owner_pid={pid}", control_lock_path.display() ), }, PidLockState::StalePid(pid) => DoctorCheck { name: "daemon_control_lock", ok: false, message: format!( "发现失效后台控制锁:{},owner_pid={pid};可执行 clean-stable 清理", control_lock_path.display() ), }, PidLockState::Corrupt => DoctorCheck { name: "daemon_control_lock", ok: false, message: format!( "发现损坏后台控制锁:{};可执行 clean-stable 清理", control_lock_path.display() ), }, }); 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 safety_check(name: &'static str, result: Result<(), String>, ok_message: &str) -> DoctorCheck { match result { Ok(()) => DoctorCheck { name, ok: true, message: ok_message.to_string(), }, Err(error) => DoctorCheck { name, ok: false, message: error, }, } } 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()), }, } } fn proxy_check(config: &CurlProxyConfig) -> DoctorCheck { let resolved = resolve_curl_proxy(config); // 对解析出的代理 URL(含 auto 模式从环境变量取得的)做 scheme 校验, // 让 doctor 能在前置阶段暴露拼错的代理配置,而非恒为 ok。 let (ok, message) = match resolved.url.as_deref() { Some(url) => match validate_proxy_url(url) { Ok(()) => (true, resolved.human_summary()), Err(error) => (false, format!("{};{error}", resolved.human_summary())), }, None => (true, resolved.human_summary()), }; DoctorCheck { name: "proxy", ok, message, } } #[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<()> { validate_output_root(&options.config.output_root).map_err(anyhow::Error::msg)?; validate_runtime_state_dir(&options.state_dir).map_err(anyhow::Error::msg)?; 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); match classify_pid_lock_file(&pid_path)? { PidLockState::Missing => {} PidLockState::Active(_) => skipped_paths.push(pid_path), PidLockState::StalePid(_) | PidLockState::Corrupt => { fs::remove_file(&pid_path)?; removed_paths.push(pid_path); } } let socket_path = daemon_socket_path(&options.state_dir); if daemon_socket_path_exists(&socket_path)? { 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(); match classify_pid_lock_file(&lock_path)? { PidLockState::Missing => {} PidLockState::Active(_) => skipped_paths.push(lock_path), PidLockState::StalePid(_) | PidLockState::Corrupt => { fs::remove_file(&lock_path)?; removed_paths.push(lock_path); } } let control_lock_path = daemon_control_lock_path(&options.state_dir); match classify_pid_lock_file(&control_lock_path)? { PidLockState::Missing => {} PidLockState::Active(pid) => { return Err(anyhow::anyhow!( "后台控制命令已被锁定 (locked):{};owner_pid={pid} 仍在运行", control_lock_path.display() )); } PidLockState::StalePid(_) | PidLockState::Corrupt => { fs::remove_file(&control_lock_path)?; removed_paths.push(control_lock_path); } } // 后台已停止,清除残留的代理凭据文件,不把凭据留在磁盘上。 let proxy_secret_path = daemon_proxy_secret_path(&options.state_dir); if proxy_secret_path.exists() { fs::remove_file(&proxy_secret_path)?; removed_paths.push(proxy_secret_path); } // 后台已停止,清理未被版本状态引用的孤儿 staging 目录。 if let Some(state) = read_version_state(&options.config.version_state_path())? { removed_paths.extend(gc_orphan_staging(&options.config.output_root, &state)?); } 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 Some(bytes) = read_file_no_symlink(path, "后台状态文件").map_err(anyhow::Error::msg)? else { return Ok(None); }; Ok(Some(serde_json::from_slice(&bytes)?)) } fn write_daemon_status_file(path: &Path, status: &DaemonStatusFile) -> anyhow::Result<()> { write_file_atomic( path, &serde_json::to_vec_pretty(status)?, PRIVATE_FILE_MODE, "后台状态文件", ) .map_err(anyhow::Error::msg)?; Ok(()) } fn update_daemon_status(state_dir: &Path, update: DaemonStatusUpdate<'_>) -> 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), structured_log_path: Some(daemon_structured_log_path(state_dir)), started_unix_seconds: unix_seconds_now(), updated_unix_seconds: unix_seconds_now(), last_success_unix_seconds: None, next_check_unix_seconds: None, last_update_status: None, last_error: None, next_retry_seconds: None, current_stage: None, current_message: None, download_progress: None, pending_scheduled_force: false, next_forced_refresh_unix_seconds: None, command: env::args().collect(), }); status.pid = std::process::id(); status.state = update.state.to_string(); status.updated_unix_seconds = unix_seconds_now(); status.last_update_status = update.last_update_status; status.last_error = update.last_error; status.next_retry_seconds = update.next_retry_seconds; if update.last_success_unix_seconds.is_some() { status.last_success_unix_seconds = update.last_success_unix_seconds; } status.next_check_unix_seconds = update.next_check_unix_seconds; if update.state != "running" { status.current_stage = None; status.current_message = None; status.download_progress = None; } status.pending_scheduled_force = update.pending_scheduled_force; status.next_forced_refresh_unix_seconds = system_time_to_unix_seconds(update.next_forced_refresh_at); write_daemon_status_file(&status_path, &status) } fn update_daemon_progress(state_dir: &Path, event: &OfficialUpdateProgress) -> 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.updated_unix_seconds = unix_seconds_now(); status.current_stage = Some(event.stage.to_string()); status.current_message = Some(event.message.clone()); status.download_progress = match (event.download_index, event.download_total) { (Some(index), Some(total)) => Some(DaemonDownloadProgress { index, total, url: event.download_url.clone().unwrap_or_default(), status: event.download_status.clone(), bytes: event.download_bytes, transferred_bytes: event.download_transferred_bytes, failure_kind: event.download_failure_kind.clone(), failure_http_status: event.download_failure_http_status, failure_retryable: event.download_failure_retryable, failure_attempts: event.download_failure_attempts, quarantined: event.download_quarantined, }), _ if event.stage != "download" => None, _ => status.download_progress, }; 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 Some(bytes) = read_file_no_symlink(path, "后台 PID 文件").map_err(anyhow::Error::msg)? else { return Ok(None); }; let contents = String::from_utf8(bytes)?; 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_structured_log_path(output_root: &Path) -> PathBuf { output_root.join(DAEMON_STRUCTURED_LOG_FILE) } fn rotated_structured_log_path(path: &Path, index: usize) -> PathBuf { let file_name = path .file_name() .and_then(|value| value.to_str()) .unwrap_or(DAEMON_STRUCTURED_LOG_FILE); path.with_file_name(format!("{file_name}.{index}")) } fn rotated_structured_log_paths(path: &Path) -> Vec { (1..=STRUCTURED_LOG_ROTATE_KEEP) .map(|index| rotated_structured_log_path(path, index)) .filter(|path| path_exists_no_follow(path).unwrap_or(false)) .collect() } fn daemon_socket_path(output_root: &Path) -> PathBuf { output_root.join(DAEMON_SOCKET_FILE) } fn daemon_control_lock_path(output_root: &Path) -> PathBuf { output_root.join(DAEMON_CONTROL_LOCK_FILE) } fn daemon_proxy_secret_path(state_dir: &Path) -> PathBuf { state_dir.join(DAEMON_PROXY_SECRET_FILE) } /// 返回 URL 型代理的凭据字符串;auto/disabled 模式返回 None。 fn curl_proxy_url(config: &CurlProxyConfig) -> Option { match config.mode() { CurlProxyMode::Url(url) => Some(url.clone()), CurlProxyMode::Auto | CurlProxyMode::Disabled => None, } } /// 将代理凭据写入专用 0600 文件,供 restart/reload 复用;该文件永不进入状态输出。 fn write_daemon_proxy_secret(state_dir: &Path, url: &str) -> anyhow::Result<()> { write_file_atomic( &daemon_proxy_secret_path(state_dir), url.as_bytes(), PRIVATE_FILE_MODE, "代理凭据文件", ) .map_err(anyhow::Error::msg) } /// 删除代理凭据文件(best-effort);无代理或拆除后台时调用。 fn clear_daemon_proxy_secret(state_dir: &Path) { let _ = fs::remove_file(daemon_proxy_secret_path(state_dir)); } /// 读取代理凭据文件;缺失或为空返回 None。 fn read_daemon_proxy_secret(state_dir: &Path) -> anyhow::Result> { let Some(bytes) = read_file_no_symlink(&daemon_proxy_secret_path(state_dir), "代理凭据文件") .map_err(anyhow::Error::msg)? else { return Ok(None); }; let url = String::from_utf8(bytes).map_err(|_| anyhow::anyhow!("代理凭据文件不是有效 UTF-8"))?; let trimmed = url.trim(); if trimmed.is_empty() { Ok(None) } else { Ok(Some(trimmed.to_string())) } } fn active_official_resource_root(output_root: &Path) -> anyhow::Result { let current_path = output_root.join(OFFICIAL_CURRENT_LINK); let metadata = match fs::symlink_metadata(¤t_path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return Ok(output_root.to_path_buf()); } Err(error) => return Err(error.into()), }; if !metadata.file_type().is_symlink() { return Err(anyhow::anyhow!( "current 已存在但不是 symlink:{}", current_path.display() )); } let target = fs::read_link(¤t_path)?; let target = if target.is_absolute() { target } else { output_root.join(target) }; lexical_absolute(&target).map_err(anyhow::Error::msg) } 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()); match config.curl_proxy.mode() { CurlProxyMode::Auto if options.proxy_option_explicit => { args.push("--proxy".to_string()); args.push("auto".to_string()); } CurlProxyMode::Auto => {} CurlProxyMode::Disabled => { args.push("--no-proxy".to_string()); } CurlProxyMode::Url(_) => { // 凭据经 PROXY_URL_ENV_VAR 环境变量下传子进程;此处只放不含凭据的 flag, // 避免代理 URL 进入子进程 argv(/proc//cmdline)与状态文件 command 字段。 args.push(PROXY_FROM_ENV_FLAG.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.is_multiple_of(3_600_000) { format!("{}h", millis / 3_600_000) } else if millis.is_multiple_of(60_000) { format!("{}m", millis / 60_000) } else if millis.is_multiple_of(1_000) { 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 unix_seconds_after(duration: Duration) -> u64 { unix_seconds_now().saturating_add(duration.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 { forced_refresh_time(now, ForcedRefreshBoundary::AtOrAfter) } fn next_forced_refresh_after(now: SystemTime) -> SystemTime { forced_refresh_time(now, ForcedRefreshBoundary::After) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ForcedRefreshBoundary { AtOrAfter, After, } fn forced_refresh_time(now: SystemTime, boundary: ForcedRefreshBoundary) -> SystemTime { let local_seconds = beijing_local_seconds_since_epoch(now); let local_day = local_seconds / SECONDS_PER_DAY; let local_second_of_day = local_seconds % SECONDS_PER_DAY; for scheduled_second in DAILY_FORCED_REFRESH_LOCAL_SECONDS { let matches_boundary = match boundary { ForcedRefreshBoundary::AtOrAfter => scheduled_second >= local_second_of_day, ForcedRefreshBoundary::After => scheduled_second > local_second_of_day, }; if matches_boundary { return system_time_from_beijing_local_seconds( local_day .saturating_mul(SECONDS_PER_DAY) .saturating_add(scheduled_second), ); } } system_time_from_beijing_local_seconds( local_day .saturating_add(1) .saturating_mul(SECONDS_PER_DAY) .saturating_add(DAILY_FORCED_REFRESH_LOCAL_SECONDS[0]), ) } fn beijing_local_seconds_since_epoch(time: SystemTime) -> u64 { time.duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() .saturating_add(BEIJING_UTC_OFFSET_SECONDS) } fn system_time_from_beijing_local_seconds(local_seconds: u64) -> SystemTime { UNIX_EPOCH + Duration::from_secs(local_seconds.saturating_sub(BEIJING_UTC_OFFSET_SECONDS)) } fn duration_until(deadline: SystemTime, now: SystemTime) -> Duration { deadline.duration_since(now).unwrap_or_default() } #[derive(Debug, Clone)] struct ProgressLogger { enabled: bool, started_at: Instant, structured: Option, } impl ProgressLogger { fn new(enabled: bool) -> Self { Self { enabled, started_at: Instant::now(), structured: None, } } fn attach_structured_log(&mut self, path: PathBuf) { self.structured = Some(RotatingStructuredLogger::new( path, STRUCTURED_LOG_MAX_BYTES, STRUCTURED_LOG_ROTATE_KEEP, )); } fn log(&mut self, event: OfficialUpdateProgress) { self.log_event(&event); } fn log_text(&mut self, stage: &str, message: impl AsRef) { self.log_text_inner(stage, message.as_ref()); } fn log_event(&mut self, event: &OfficialUpdateProgress) { if self.enabled { eprintln!( "[+{} 信息] [{}] {}", format_duration(self.started_at.elapsed()), localized_stage(event.stage), event.message ); } if let Some(structured) = self.structured.as_mut() { let _ = structured.write_event(self.started_at.elapsed(), event); } } fn log_text_inner(&mut self, stage: &str, message: &str) { if !self.enabled { if let Some(structured) = self.structured.as_mut() { let event = OfficialUpdateProgress::new(stage_to_static(stage), message); let _ = structured.write_event(self.started_at.elapsed(), &event); } return; } eprintln!( "[+{} 信息] [{}] {}", format_duration(self.started_at.elapsed()), localized_stage(stage), message ); if let Some(structured) = self.structured.as_mut() { let event = OfficialUpdateProgress::new(stage_to_static(stage), message); let _ = structured.write_event(self.started_at.elapsed(), &event); } } } #[derive(Debug, Clone)] struct RotatingStructuredLogger { path: PathBuf, max_bytes: u64, keep: usize, } impl RotatingStructuredLogger { fn new(path: PathBuf, max_bytes: u64, keep: usize) -> Self { Self { path, max_bytes, keep, } } fn write_event( &mut self, elapsed: Duration, event: &OfficialUpdateProgress, ) -> anyhow::Result<()> { let payload = serde_json::json!({ "timestamp_unix_seconds": unix_seconds_now(), "elapsed_ms": elapsed.as_millis(), "level": "info", "stage": event.stage, "stage_label": localized_stage(event.stage), "message": event.message.as_str(), "download": event.download_index.map(|index| serde_json::json!({ "index": index, "total": event.download_total.unwrap_or(index), "url": event.download_url.as_deref(), "status": event.download_status.as_deref(), "bytes": event.download_bytes, "transferred_bytes": event.download_transferred_bytes, "failure_kind": event.download_failure_kind.as_deref(), "failure_http_status": event.download_failure_http_status, "failure_retryable": event.download_failure_retryable, "failure_attempts": event.download_failure_attempts, "quarantined": event.download_quarantined, })), }); let mut line = serde_json::to_vec(&payload)?; line.push(b'\n'); self.rotate_if_needed(line.len() as u64)?; let mut file = open_append_file(&self.path, PRIVATE_FILE_MODE, "结构化日志") .map_err(anyhow::Error::msg)?; file.write_all(&line)?; file.flush()?; Ok(()) } fn rotate_if_needed(&self, incoming_bytes: u64) -> anyhow::Result<()> { let current_len = match fs::symlink_metadata(&self.path) { Ok(metadata) if metadata.file_type().is_symlink() => { return Err(anyhow::anyhow!( "结构化日志不能是 symlink:{}", self.path.display() )) } Ok(metadata) if metadata.is_file() => metadata.len(), Ok(_) => { return Err(anyhow::anyhow!( "结构化日志已存在但不是普通文件:{}", self.path.display() )) } Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0, Err(error) => return Err(error.into()), }; if current_len.saturating_add(incoming_bytes) <= self.max_bytes { return Ok(()); } for index in (1..=self.keep).rev() { let from = if index == 1 { self.path.clone() } else { rotated_structured_log_path(&self.path, index - 1) }; let to = rotated_structured_log_path(&self.path, index); if !path_exists_no_follow(&from)? { continue; } if path_exists_no_follow(&to)? { fs::remove_file(&to)?; } fs::rename(&from, &to)?; } Ok(()) } } fn stage_to_static(stage: &str) -> &'static str { match stage { "start" => "start", "lock" => "lock", "bootstrap" => "bootstrap", "launcher" => "launcher", "bootstrap-cache" => "bootstrap-cache", "game-main-config" => "game-main-config", "metadata" => "metadata", "server-info" => "server-info", "discovery" => "discovery", "markers" => "markers", "marker" => "marker", "catalog" => "catalog", "local-state" => "local-state", "audit" => "audit", "decision" => "decision", "plan" => "plan", "download" => "download", "snapshot" => "snapshot", "publish" => "publish", "finish" => "finish", "watch" => "watch", "daemon" => "daemon", "dry-run" => "dry-run", _ => "log", } } 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" => "下载", "publish" => "发布", "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}"); } fn should_print_status(status: OfficialUpdateStatus, quiet_up_to_date: bool) -> bool { !(quiet_up_to_date && status == OfficialUpdateStatus::UpToDate) } fn parse_args() -> anyhow::Result { parse_args_from(env::args()) } 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".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)?); } "--proxy" => { options.config.curl_proxy = parse_proxy_config(&next_option_value(&mut args, &flag)?)?; options.proxy_option_explicit = true; } "--no-proxy" => { options.config.curl_proxy = CurlProxyConfig::disabled(); options.proxy_option_explicit = true; } "--proxy-from-env" => { // 后台子进程内部 flag:代理凭据从环境变量读取,随后立即清除, // 避免被 curl 等孙进程继承。 let url = env::var(PROXY_URL_ENV_VAR).map_err(|_| { anyhow::anyhow!("{PROXY_FROM_ENV_FLAG} 需要设置 {PROXY_URL_ENV_VAR} 环境变量") })?; env::remove_var(PROXY_URL_ENV_VAR); options.config.curl_proxy = CurlProxyConfig::url(url); options.proxy_option_explicit = true; } "--unzip" => { options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?); } "--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!("{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!("{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; options.banner = true; } "--no-progress" => { 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!("未知参数:{flag}")), } } match options.command { CliCommand::Status | CliCommand::Stop | CliCommand::Logs => { if options.sync_option_explicit || options.output_explicit || options.proxy_option_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、--proxy 和 --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 || options.proxy_option_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.daemon && options.watch { return Err(anyhow::anyhow!( "--daemon 会自动启动 watch 模式,不要同时传 --watch" )); } 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 && !options.config.audit_local { 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 { let defaults = OfficialUpdateConfig::default(); config.curl_command != defaults.curl_command || config.curl_proxy != defaults.curl_proxy || config.unzip_command != defaults.unzip_command } fn next_option_value( args: &mut impl Iterator, flag: &str, ) -> anyhow::Result { args.next() .ok_or_else(|| anyhow::anyhow!("{flag} 缺少参数值")) } /// curl 支持的代理 scheme。 const SUPPORTED_PROXY_SCHEMES: [&str; 6] = ["http", "https", "socks4", "socks4a", "socks5", "socks5h"]; /// 校验显式代理 URL 的 scheme,尽早拒绝拼错的 scheme(如 `htp://`)。 /// /// 无 `://` 时 curl 默认按 http 处理 `host:port`,此处只要求非空;带 `://` 时 /// scheme 必须是 curl 支持的代理协议,且代理主机部分不能为空。 fn validate_proxy_url(url: &str) -> anyhow::Result<()> { if let Some((scheme, rest)) = url.split_once("://") { let scheme_lower = scheme.to_ascii_lowercase(); if !SUPPORTED_PROXY_SCHEMES.contains(&scheme_lower.as_str()) { return Err(anyhow::anyhow!( "--proxy 使用了不支持的 scheme:{scheme}(支持 {})", SUPPORTED_PROXY_SCHEMES.join("/") )); } if rest.is_empty() { return Err(anyhow::anyhow!("--proxy 缺少代理主机:{url}")); } } Ok(()) } fn parse_proxy_config(value: &str) -> anyhow::Result { let normalized = value.trim(); if normalized.is_empty() { return Err(anyhow::anyhow!("--proxy 不能为空")); } match normalized.to_ascii_lowercase().as_str() { "auto" | "env" => Ok(CurlProxyConfig::auto()), "none" | "direct" | "off" | "disabled" => Ok(CurlProxyConfig::disabled()), _ => { validate_proxy_url(normalized)?; Ok(CurlProxyConfig::url(normalized.to_string())) } } } 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!( " 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!( " --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 publish root (default: ./bat-resources)" ); eprintln!(" --snapshot Override snapshot path (default: /current/official-sync-snapshot.json)"); eprintln!(" --curl curl executable (default: curl)"); eprintln!(" --proxy curl proxy override (default: auto from env)"); eprintln!(" --no-proxy Force direct curl connections"); 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 (current -> versions/, .staging/)"); eprintln!(" daemon state: /tmp/bat-pid (bat.sock, bat.pid, bat-status.json, bat-daemon.log, bat-events.jsonl)"); 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!("时间间隔不能为空")); } let (number, multiplier) = if let Some(number) = value.strip_suffix("ms") { let millis = number .parse::() .map_err(|error| anyhow::anyhow!("毫秒时间间隔无效:{value}:{error}"))?; return Ok(Duration::from_millis(millis)); } else if let Some(number) = value.strip_suffix('s') { (number, 1) } else if let Some(number) = value.strip_suffix('m') { (number, 60) } else if let Some(number) = value.strip_suffix('h') { (number, 60 * 60) } else { (value, 1) }; let amount = number .parse::() .map_err(|error| anyhow::anyhow!("时间间隔无效:{value}:{error}"))?; Ok(Duration::from_secs(amount.saturating_mul(multiplier))) } fn format_duration(duration: Duration) -> String { let total_millis = duration.as_millis(); let minutes = total_millis / 60_000; let seconds = (total_millis / 1_000) % 60; let millis = total_millis % 1_000; format!("{minutes:02}:{seconds:02}.{millis:03}") } fn parse_platforms(value: &str) -> Result, String> { value .split(',') .map(str::trim) .filter(|part| !part.is_empty()) .map(parse_platform) .collect() } fn parse_platform(value: &str) -> Result { match value.to_ascii_lowercase().as_str() { "windows" | "win" => Ok(PatchPlatform::Windows), "android" => Ok(PatchPlatform::Android), _ => Err(format!("不支持的平台:{value}")), } } #[cfg(test)] mod tests { use super::*; fn parse(values: &[&str]) -> anyhow::Result { parse_args_from(values.iter().map(|value| value.to_string())) } #[test] fn parses_auto_discover_sync_args() { let options = parse(&[ "bat", "--auto-discover", "--platforms", "Windows,Android", "--snapshot", "/tmp/snapshot.json", "--dry-run", "--plan", ]) .unwrap(); let config = options.config; assert!(config.auto_discover); assert_eq!( config.platforms.as_deref(), Some([PatchPlatform::Windows, PatchPlatform::Android].as_slice()) ); assert_eq!( config.snapshot_path, Some(PathBuf::from("/tmp/snapshot.json")) ); assert!(config.dry_run); assert!(config.plan); assert!(config.audit_local); assert!(config.repair); assert!(!options.watch); assert_eq!( options.interval, Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS) ); assert_eq!( options.error_retry_interval, Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS) ); assert!(!options.quiet_up_to_date); 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", "--server-info-url", "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json", "--connection-group", "Prod-Audit", "--app-version", "1.70.0", "--no-audit-local", "--no-repair", "--unzip", "/usr/bin/unzip", ]) .unwrap(); let config = options.config; assert!(!config.auto_discover); assert_eq!(config.connection_group.as_deref(), Some("Prod-Audit")); assert_eq!(config.app_version.as_deref(), Some("1.70.0")); assert!(!config.audit_local); assert!(!config.repair); assert_eq!(config.unzip_command, PathBuf::from("/usr/bin/unzip")); assert!(matches!( config.server_info_source, Some(OfficialServerInfoSource::OfficialUrl(ref url)) if url == "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" )); } #[test] fn parses_proxy_options() { let options = parse(&["bat", "--proxy", "http://127.0.0.1:7890"]).unwrap(); assert!(options.proxy_option_explicit); assert_eq!( options.config.curl_proxy.mode(), &CurlProxyMode::Url("http://127.0.0.1:7890".to_string()) ); let options = parse(&["bat", "--proxy", "none"]).unwrap(); assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Disabled); let options = parse(&["bat", "--no-proxy"]).unwrap(); assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Disabled); let options = parse(&["bat", "--proxy", "auto"]).unwrap(); assert!(options.proxy_option_explicit); assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Auto); // socks5 等受支持 scheme 可用。 let options = parse(&["bat", "--proxy", "socks5://127.0.0.1:1080"]).unwrap(); assert_eq!( options.config.curl_proxy.mode(), &CurlProxyMode::Url("socks5://127.0.0.1:1080".to_string()) ); } #[test] fn rejects_proxy_with_unsupported_scheme() { let error = parse(&["bat", "--proxy", "htp://127.0.0.1:7890"]).unwrap_err(); assert!(error.to_string().contains("不支持的 scheme")); // 无 scheme 的 host:port 仍接受(curl 默认按 http 处理)。 assert!(parse(&["bat", "--proxy", "127.0.0.1:7890"]).is_ok()); } #[test] fn parses_watch_defaults_to_one_hour_and_quiet_up_to_date() { let options = parse(&["bat", "--auto-discover", "--watch", "--interval", "30m"]).unwrap(); assert!(options.watch); assert_eq!(options.interval, Duration::from_secs(30 * 60)); assert_eq!( options.error_retry_interval, Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS) ); assert!(options.quiet_up_to_date); assert!(!options.quiet_up_to_date_explicit); } #[test] fn parses_daemon_mode_as_background_watch() { let options = parse(&[ "bat", "--auto-discover", "--daemon", "--output", "/tmp/daemon-output", "--interval", "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", "--proxy", "http://127.0.0.1:7890", "--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"])); // 代理凭据不得进入子进程 argv:只放不含凭据的 flag,URL 经环境变量下传。 assert!(args.contains(&PROXY_FROM_ENV_FLAG.to_string())); assert!(!args.iter().any(|arg| arg.contains("127.0.0.1:7890"))); assert!(!args.windows(2).any(|pair| pair[0] == "--proxy")); 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 curl_proxy_url_extracts_only_url_mode() { assert_eq!( curl_proxy_url(&CurlProxyConfig::url("http://user:secret@127.0.0.1:7890")), Some("http://user:secret@127.0.0.1:7890".to_string()) ); assert_eq!(curl_proxy_url(&CurlProxyConfig::auto()), None); assert_eq!(curl_proxy_url(&CurlProxyConfig::disabled()), None); } #[test] fn daemon_proxy_secret_round_trip_and_clear() { let dir = tempfile::TempDir::new().unwrap(); let state_dir = dir.path(); assert_eq!(read_daemon_proxy_secret(state_dir).unwrap(), None); write_daemon_proxy_secret(state_dir, "http://user:secret@127.0.0.1:7890").unwrap(); assert_eq!( read_daemon_proxy_secret(state_dir).unwrap(), Some("http://user:secret@127.0.0.1:7890".to_string()) ); // 凭据文件以 0600 创建。 #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = fs::metadata(daemon_proxy_secret_path(state_dir)) .unwrap() .permissions() .mode() & 0o777; assert_eq!(mode, 0o600); } clear_daemon_proxy_secret(state_dir); assert_eq!(read_daemon_proxy_secret(state_dir).unwrap(), None); } // 序列化会修改进程级 PROXY_URL_ENV_VAR 的测试,避免并行测试相互干扰。 static PROXY_ENV_TEST_LOCK: Mutex<()> = Mutex::new(()); #[test] fn proxy_from_env_flag_reads_and_consumes_env_var() { let _guard = PROXY_ENV_TEST_LOCK .lock() .unwrap_or_else(|poison| poison.into_inner()); env::set_var(PROXY_URL_ENV_VAR, "http://user:secret@127.0.0.1:7890"); let options = parse(&["bat", "--watch", "--proxy-from-env"]).unwrap(); assert!(options.proxy_option_explicit); assert_eq!( options.config.curl_proxy.mode(), &CurlProxyMode::Url("http://user:secret@127.0.0.1:7890".to_string()) ); // 读取后应从环境中清除,避免被 curl 等孙进程继承。 assert!(env::var(PROXY_URL_ENV_VAR).is_err()); } #[test] fn proxy_from_env_flag_errors_when_env_var_missing() { let _guard = PROXY_ENV_TEST_LOCK .lock() .unwrap_or_else(|poison| poison.into_inner()); env::remove_var(PROXY_URL_ENV_VAR); let error = parse(&["bat", "--watch", "--proxy-from-env"]).unwrap_err(); assert!(error.to_string().contains(PROXY_URL_ENV_VAR)); } #[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", "--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"]).unwrap(); assert!(options.progress); assert!(options.banner); let options = parse(&["bat", "--no-progress"]).unwrap(); assert!(!options.progress); assert!(!options.banner); let options = parse(&["bat", "--no-progress", "--progress"]).unwrap(); assert!(options.progress); assert!(options.banner); } #[test] fn parses_banner_flags() { let options = parse(&["bat", "--no-banner"]).unwrap(); assert!(options.progress); assert!(!options.banner); let options = parse(&["bat", "--no-progress", "--banner"]).unwrap(); assert!(!options.progress); assert!(options.banner); } #[test] fn startup_banner_contains_product_name() { assert!(STARTUP_BANNER.contains("BlueArchiveToolkit")); } #[test] fn verification_summary_output_names_all_validation_types() { let summary = OfficialVerificationSummary::new(7, 1, 2, 3); let lines = verification_summary_lines(&summary); assert_eq!(lines.len(), 3); assert!(lines[0].contains("官方 .hash 强校验")); assert!(lines[0].contains("xxHash32")); assert!(lines[1].contains("本地 BLAKE3 复用校验")); assert!(lines[1].contains("需修复")); assert!(lines[2].contains("ZIP 结构校验")); assert!(lines[2].contains("central directory")); let json = serde_json::to_value(&summary).unwrap(); assert_eq!(json["official_hash_verified_count"], 2); assert_eq!(json["local_manifest_blake3_verified_count"], 7); assert_eq!(json["local_manifest_repair_needed_count"], 1); assert_eq!(json["zip_structure_verified_count"], 3); } #[test] fn daemon_rpc_download_progress_formats_as_human_text() { let progress = serde_json::json!({ "index": 2, "total": 19, "url": "https://prod-clientpatch.bluearchiveyostar.com/file.zip", "status": "finished", "bytes": 100, "transferred_bytes": 100 }); let formatted = format_daemon_download_progress_json(&progress).unwrap(); assert_eq!( formatted, "2/19 finished https://prod-clientpatch.bluearchiveyostar.com/file.zip" ); assert!(!formatted.contains('{')); } #[test] fn daemon_rpc_failed_download_progress_formats_as_human_text() { let progress = serde_json::json!({ "index": 3, "total": 19, "url": "https://prod-clientpatch.bluearchiveyostar.com/missing.zip", "status": "failed", "failure_kind": "http_404", "failure_http_status": 404, "failure_retryable": false, "failure_attempts": 1, "quarantined": true }); let formatted = format_daemon_download_progress_json(&progress).unwrap(); assert_eq!( formatted, "3/19 failed kind=http_404 http=404 retryable=false attempts=1 quarantined=true https://prod-clientpatch.bluearchiveyostar.com/missing.zip" ); assert!(!formatted.contains('{')); } #[test] fn daemon_status_hides_failure_superseded_by_in_progress_version() { let version = OfficialVersionRecord { id: "1.70.0-current-attempt".to_string(), app_version: "1.70.0".to_string(), bundle_version: Some("s8tloc7lo3".to_string()), addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_token" .to_string(), resource_root: PathBuf::from("/tmp/bat/.staging/current"), snapshot_path: PathBuf::from("/tmp/bat/.staging/current/official-sync-snapshot.json"), staging_path: Some(PathBuf::from("/tmp/bat/.staging/current")), version_path: None, started_unix_seconds: Some(100), completed_unix_seconds: None, }; let older_failure = OfficialFailedVersionRecord { version: OfficialVersionRecord { id: "1.70.0-older-failure".to_string(), resource_root: PathBuf::from("/tmp/bat/.staging/older"), snapshot_path: PathBuf::from("/tmp/bat/.staging/older/official-sync-snapshot.json"), staging_path: Some(PathBuf::from("/tmp/bat/.staging/older")), started_unix_seconds: Some(90), ..version.clone() }, error: "官方 hash 校验失败 fixture".to_string(), failed_unix_seconds: 95, }; let state = OfficialVersionState { in_progress_version: Some(version), failed_versions: vec![older_failure], updated_unix_seconds: 100, ..OfficialVersionState::default() }; assert!(visible_historical_failed_versions(&state).is_empty()); } #[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") ); } #[cfg(unix)] #[test] fn active_official_resource_root_uses_current_symlink() { use std::os::unix::fs::symlink; let temp = tempfile::TempDir::new().unwrap(); let output = temp.path().join("resources"); let version = output.join("versions").join("v1"); fs::create_dir_all(&version).unwrap(); symlink(Path::new("versions").join("v1"), output.join("current")).unwrap(); assert_eq!( active_official_resource_root(&output).unwrap(), lexical_absolute(&version).unwrap() ); } #[cfg(unix)] #[test] fn daemon_rpc_rejects_socket_symlink() { use std::os::unix::fs::symlink; let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path().join("state"); fs::create_dir_all(&state_dir).unwrap(); let outside_socket = temp.path().join("outside.sock"); let socket_path = daemon_socket_path(&state_dir); symlink(&outside_socket, &socket_path).unwrap(); assert!(daemon_socket_path_exists(&socket_path).unwrap()); assert!(!daemon_rpc_available(&state_dir)); let error = daemon_rpc_call(&state_dir, RPC_METHOD_STATUS, None).unwrap_err(); assert!(error.to_string().contains("symlink")); } #[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 daemon_control_lock_rejects_active_owner() { let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path().join("state"); let first = DaemonControlLock::acquire(&state_dir).unwrap(); let second = DaemonControlLock::acquire(&state_dir).unwrap_err(); assert!(second.to_string().contains("locked")); drop(first); assert!(DaemonControlLock::acquire(&state_dir).is_ok()); } #[test] fn daemon_control_lock_recovers_stale_and_corrupt_files() { let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path().join("state"); fs::create_dir_all(&state_dir).unwrap(); let lock_path = daemon_control_lock_path(&state_dir); fs::write(&lock_path, "999999999").unwrap(); let lock = DaemonControlLock::acquire(&state_dir).unwrap(); drop(lock); assert!(!lock_path.exists()); fs::write(&lock_path, "not-a-pid").unwrap(); let lock = DaemonControlLock::acquire(&state_dir).unwrap(); assert_eq!( fs::read_to_string(&lock_path).unwrap(), std::process::id().to_string() ); drop(lock); assert!(!lock_path.exists()); } #[cfg(unix)] #[test] fn daemon_control_lock_file_is_private() { use std::os::unix::fs::PermissionsExt; let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path().join("state"); let lock_path = daemon_control_lock_path(&state_dir); let lock = DaemonControlLock::acquire(&state_dir).unwrap(); let mode = fs::metadata(&lock_path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600); drop(lock); } #[cfg(unix)] #[test] fn daemon_status_file_is_private_and_rejects_symlink() { use std::os::unix::fs::{symlink, PermissionsExt}; let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path().join("state"); let output_root = temp.path().join("resources"); fs::create_dir_all(&state_dir).unwrap(); fs::create_dir_all(&output_root).unwrap(); let status_path = daemon_status_path(&state_dir); let status = test_daemon_status_file(&state_dir, &output_root); write_daemon_status_file(&status_path, &status).unwrap(); let mode = fs::metadata(&status_path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600); fs::remove_file(&status_path).unwrap(); let outside = temp.path().join("outside-status.json"); fs::write(&outside, b"outside").unwrap(); symlink(&outside, &status_path).unwrap(); let error = write_daemon_status_file(&status_path, &status).unwrap_err(); assert!(error.to_string().contains("symlink")); assert_eq!(fs::read(&outside).unwrap(), b"outside"); } #[test] fn daemon_status_tracks_progress_success_and_next_check() { let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path().join("state"); let output_root = temp.path().join("resources"); fs::create_dir_all(&state_dir).unwrap(); fs::create_dir_all(&output_root).unwrap(); write_daemon_status_file( &daemon_status_path(&state_dir), &test_daemon_status_file(&state_dir, &output_root), ) .unwrap(); let mut event = OfficialUpdateProgress::new("download", "(2/19) 开始 fixture"); event.download_index = Some(2); event.download_total = Some(19); event.download_url = Some("https://prod-clientpatch.bluearchiveyostar.com/file.zip".into()); update_daemon_progress(&state_dir, &event).unwrap(); let report = build_daemon_status_report(&state_dir).unwrap(); assert_eq!(report.current_stage.as_deref(), Some("download")); assert_eq!(report.download_progress.as_ref().unwrap().index, 2); assert_eq!(report.download_progress.as_ref().unwrap().total, 19); let next_check = unix_seconds_after(Duration::from_secs(30)); update_daemon_status( &state_dir, DaemonStatusUpdate { state: "sleeping", last_update_status: Some("downloaded".to_string()), last_error: None, next_retry_seconds: Some(30), last_success_unix_seconds: Some(123456), next_check_unix_seconds: Some(next_check), pending_scheduled_force: false, next_forced_refresh_at: SystemTime::now() + Duration::from_secs(3600), }, ) .unwrap(); let report = build_daemon_status_report(&state_dir).unwrap(); assert_eq!(report.last_success_unix_seconds, Some(123456)); assert_eq!(report.next_check_unix_seconds, Some(next_check)); assert!(report.current_stage.is_none()); assert!(report.download_progress.is_none()); } #[test] fn structured_logger_writes_jsonl_and_rotates() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join("bat-events.jsonl"); let mut logger = RotatingStructuredLogger::new(path.clone(), 180, 2); let mut event = OfficialUpdateProgress::new("download", "下载进度 fixture"); event.download_index = Some(1); event.download_total = Some(2); event.download_url = Some("https://prod-clientpatch.bluearchiveyostar.com/a.zip".into()); for _ in 0..8 { logger .write_event(Duration::from_millis(42), &event) .unwrap(); } assert!(path.exists()); assert!(rotated_structured_log_path(&path, 1).exists()); let line = fs::read_to_string(&path) .unwrap() .lines() .next() .unwrap() .to_string(); let value: serde_json::Value = serde_json::from_str(&line).unwrap(); assert_eq!(value["stage"], "download"); assert_eq!(value["download"]["index"], 1); assert_eq!(value["download"]["total"], 2); } #[cfg(unix)] #[test] fn read_pid_file_rejects_symlink() { use std::os::unix::fs::symlink; let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path().join("state"); fs::create_dir_all(&state_dir).unwrap(); let outside = temp.path().join("outside.pid"); fs::write(&outside, b"123").unwrap(); let pid_path = daemon_pid_path(&state_dir); symlink(&outside, &pid_path).unwrap(); let error = read_pid_file(&pid_path).unwrap_err(); assert!(error.to_string().contains("symlink")); } #[test] fn live_daemon_resource_conflict_detects_same_output() { let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path().join("state"); let output_root = temp.path().join("resources"); fs::create_dir_all(&output_root).unwrap(); write_daemon_status_file( &daemon_status_path(&state_dir), &test_daemon_status_file(&state_dir, &output_root), ) .unwrap(); fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap(); let conflict = live_daemon_resource_conflict(&state_dir, &output_root) .unwrap() .unwrap(); assert_eq!(conflict.pid, Some(std::process::id())); assert_eq!( conflict.target_root, normalized_abs_path(&output_root).unwrap() ); } #[test] fn live_daemon_resource_conflict_allows_different_output() { let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path().join("state"); let daemon_output = temp.path().join("daemon-resources"); let manual_output = temp.path().join("manual-resources"); fs::create_dir_all(&daemon_output).unwrap(); fs::create_dir_all(&manual_output).unwrap(); write_daemon_status_file( &daemon_status_path(&state_dir), &test_daemon_status_file(&state_dir, &daemon_output), ) .unwrap(); fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap(); assert!(live_daemon_resource_conflict(&state_dir, &manual_output) .unwrap() .is_none()); } #[test] fn direct_write_rejects_live_daemon_for_same_output() { let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path().join("state"); let output_root = temp.path().join("resources"); fs::create_dir_all(&output_root).unwrap(); write_daemon_status_file( &daemon_status_path(&state_dir), &test_daemon_status_file(&state_dir, &output_root), ) .unwrap(); fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap(); let options = CliOptions { state_dir, config: OfficialUpdateConfig { output_root, ..OfficialUpdateConfig::default() }, ..CliOptions::default() }; let error = assert_no_live_daemon_output_conflict(&options, "repair").unwrap_err(); assert!(error.to_string().contains("同一资源目录")); assert!(error.to_string().contains("locked")); } #[test] fn clean_stable_removes_corrupt_pid_and_lock_files() { let temp = tempfile::TempDir::new().unwrap(); let output_root = temp.path().join("resources"); let state_dir = temp.path().join("state"); fs::create_dir_all(&output_root).unwrap(); fs::create_dir_all(&state_dir).unwrap(); let resource_lock = output_root.join(".official-sync.lock"); let control_lock = daemon_control_lock_path(&state_dir); let pid_path = daemon_pid_path(&state_dir); fs::write(&resource_lock, "not-a-pid").unwrap(); fs::write(&control_lock, "not-a-pid").unwrap(); fs::write(&pid_path, "not-a-pid").unwrap(); let options = CliOptions { command: CliCommand::CleanStable, output_format: OutputFormat::Json, state_dir, config: OfficialUpdateConfig { output_root, ..OfficialUpdateConfig::default() }, ..CliOptions::default() }; run_clean_stable_command(&options).unwrap(); assert!(!resource_lock.exists()); assert!(!control_lock.exists()); assert!(!pid_path.exists()); } #[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", "--watch", "--no-quiet-up-to-date", "--interval", "1h", ]) .unwrap(); assert!(options.watch); assert!(!options.quiet_up_to_date); assert!(options.quiet_up_to_date_explicit); } #[test] fn rejects_watch_dry_run_and_zero_interval() { let error = parse(&["bat", "--watch", "--dry-run"]).unwrap_err(); assert!(error.to_string().contains("不能和 --dry-run")); let error = parse(&["bat", "--daemon", "--watch"]).unwrap_err(); assert!(error.to_string().contains("不要同时传 --watch")); 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] fn parses_duration_units() { assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600)); assert_eq!(parse_duration("60m").unwrap(), Duration::from_secs(3600)); assert_eq!(parse_duration("90s").unwrap(), Duration::from_secs(90)); assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250)); 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; assert_eq!( next_forced_refresh_at_or_after(beijing_time(day, 2, 30, 0)), beijing_time(day, 3, 0, 0) ); assert_eq!( next_forced_refresh_at_or_after(beijing_time(day, 3, 0, 0)), beijing_time(day, 3, 0, 0) ); assert_eq!( next_forced_refresh_after(beijing_time(day, 3, 0, 0)), beijing_time(day, 16, 0, 0) ); assert_eq!( next_forced_refresh_after(beijing_time(day, 18, 0, 0)), beijing_time(day + 1, 3, 0, 0) ); assert_eq!( duration_until( next_forced_refresh_at_or_after(beijing_time(day, 17, 45, 0)), beijing_time(day, 17, 45, 0) ), Duration::from_secs(15 * 60) ); } #[test] fn formats_progress_durations() { assert_eq!(format_duration(Duration::from_millis(7)), "00:00.007"); assert_eq!(format_duration(Duration::from_millis(65_432)), "01:05.432"); } #[test] fn quiet_up_to_date_suppresses_only_clean_reports() { assert!(!should_print_status(OfficialUpdateStatus::UpToDate, true)); assert!(should_print_status(OfficialUpdateStatus::Downloaded, true)); assert!(should_print_status( OfficialUpdateStatus::WouldDownload, true )); assert!(should_print_status(OfficialUpdateStatus::UpToDate, false)); } #[test] fn status_string_is_stable() { assert_eq!(OfficialUpdateStatus::UpToDate.as_str(), "up_to_date"); assert_eq!( OfficialUpdateStatus::WouldDownload.as_str(), "would_download" ); assert_eq!(OfficialUpdateStatus::Downloaded.as_str(), "downloaded"); } #[test] fn redacts_proxy_credentials_in_surfaced_command() { let command = vec![ "/opt/bat".to_string(), "--auto-discover".to_string(), "--proxy".to_string(), "http://user:secret@127.0.0.1:7890".to_string(), "--output".to_string(), "/var/lib/bat".to_string(), ]; let redacted = redact_command_proxy_credentials(command); assert_eq!(redacted[3], "http://@127.0.0.1:7890"); assert!(!redacted.join(" ").contains("secret")); // 非代理参数保持原样。 assert_eq!(redacted[1], "--auto-discover"); assert_eq!(redacted[5], "/var/lib/bat"); } #[test] fn redacts_schemeless_proxy_credentials_in_surfaced_command() { let command = vec![ "/opt/bat".to_string(), "--proxy".to_string(), "user:secret@127.0.0.1:7890".to_string(), ]; let redacted = redact_command_proxy_credentials(command); assert_eq!(redacted[2], "@127.0.0.1:7890"); assert!(!redacted.join(" ").contains("secret")); } #[test] fn leaves_command_without_proxy_untouched() { let command = vec![ "/opt/bat".to_string(), "--auto-discover".to_string(), "--proxy".to_string(), "auto".to_string(), ]; let redacted = redact_command_proxy_credentials(command.clone()); assert_eq!(redacted, command); } fn beijing_time(day: u64, hour: u64, minute: u64, second: u64) -> SystemTime { system_time_from_beijing_local_seconds( day.saturating_mul(SECONDS_PER_DAY) .saturating_add(hour.saturating_mul(60 * 60)) .saturating_add(minute.saturating_mul(60)) .saturating_add(second), ) } fn test_daemon_status_file(state_dir: &Path, output_root: &Path) -> DaemonStatusFile { DaemonStatusFile { version: DAEMON_STATUS_VERSION, pid: std::process::id(), state: "sleeping".to_string(), resource_output_root: output_root.to_path_buf(), state_dir: state_dir.to_path_buf(), log_path: daemon_log_path(state_dir), structured_log_path: Some(daemon_structured_log_path(state_dir)), started_unix_seconds: unix_seconds_now(), updated_unix_seconds: unix_seconds_now(), last_success_unix_seconds: Some(unix_seconds_now()), next_check_unix_seconds: Some(unix_seconds_after(Duration::from_secs(60))), last_update_status: Some("up_to_date".to_string()), last_error: None, next_retry_seconds: Some(60), current_stage: None, current_message: None, download_progress: None, pending_scheduled_force: false, next_forced_refresh_unix_seconds: None, command: vec!["bat".to_string(), "--daemon-child".to_string()], } } }