use bat_adapters::official::yostar_jp::PatchPlatform; use bat_core::{ApiError, ErrorCode}; use bat_infrastructure::{ changed_endpoint_urls, diff_extended_snapshot, gc_orphan_staging, lexical_absolute, open_append_file, read_download_manifest_at, read_file_no_symlink, read_snapshot, read_version_state, redact_proxy_url, resolve_curl_proxy, validate_output_root, validate_runtime_state_dir, write_file_atomic, CurlProxyConfig, CurlProxyMode, OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PRIVATE_FILE_MODE, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; 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::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, 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: 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 { bootstrap_env_file(); 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, /// 环境变量(含 .env)应用后、命令行解析前的配置快照。 /// 工具/代理"是否命令行显式传入"的判断以它为基线。 env_baseline_config: OfficialUpdateConfig, } 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, env_baseline_config: OfficialUpdateConfig::default(), } } } #[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 }; // 进程内同步锁:watch 循环与任务 worker 在跑同步前都获取它,互相等待而非撞文件锁失败。 let sync_lock = Arc::new(Mutex::new(())); let (_task_worker, _task_context, _rpc_server) = if let Some(control) = daemon_control.as_ref() { // 任务历史持久化在 state dir(此前已通过 validate_runtime_state_dir 校验)。 let (registry, restore_summary) = TaskRegistry::with_persistence(&daemon_state_dir); logger.log_text("daemon", format!("任务历史:{restore_summary}")); let (task_tx, task_rx) = mpsc::channel::(); let worker = { let registry = registry.clone(); let sync_lock = Arc::clone(&sync_lock); let control = Arc::clone(control); thread::Builder::new() .name("bat-daemon-task-worker".to_string()) .spawn(move || run_task_worker(task_rx, registry, sync_lock, control))? }; let context = DaemonTaskContext { registry, queue: task_tx, base_config: options.config.clone(), }; let server = start_daemon_rpc_server(&daemon_state_dir, Arc::clone(control), context.clone())?; (Some(worker), Some(context), Some(server)) } else { (None, None, 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, }, ); // 只在实际执行同步的这段持有 sync_lock,与任务 worker 互斥;空闲睡眠时不持锁。 let run_result = { let _sync_guard = sync_lock .lock() .unwrap_or_else(|poison| poison.into_inner()); 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()), ) }; match run_result { 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(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), error ), ); let payload = ErrorReport { status: "error", exit_code: EXIT_ERROR, error: 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, } // 规范方法名采用国际惯例的 `.`。`bat.*` 保留为向后兼容别名。 const RPC_METHOD_STATUS: &str = "daemon.status"; const RPC_METHOD_STOP: &str = "daemon.stop"; const RPC_METHOD_RELOAD: &str = "daemon.reload"; const RPC_METHOD_REFRESH: &str = "daemon.refresh"; const RPC_METHOD_LOGS: &str = "daemon.logs"; const RPC_METHOD_RESOURCE_STATE: &str = "resource.state"; const RPC_METHOD_RESOURCE_SYNC: &str = "resource.sync"; const RPC_METHOD_RESOURCE_VERIFY: &str = "resource.verify"; const RPC_METHOD_RESOURCE_MANIFEST: &str = "resource.manifest"; const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status"; const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions"; const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff"; const RPC_METHOD_CATALOG_REFRESH: &str = "catalog.refresh"; const RPC_METHOD_TASK_STATUS: &str = "task.status"; const RPC_METHOD_TASK_LIST: &str = "task.list"; const RPC_METHOD_TASK_CANCEL: &str = "task.cancel"; const RPC_METHOD_TASK_LOGS: &str = "task.logs"; /// 保留的已完成任务上限(内存态,超出后裁剪最旧的已结束任务)。 const MAX_RETAINED_TASKS: usize = 64; /// 每个任务保留的进度日志行数上限。 const MAX_TASK_LOG_LINES: usize = 200; /// 任务类型:目前覆盖官方同步、校验与 catalog 更新检查。 #[derive(Debug, Clone, Copy)] enum TaskKind { Sync, Verify, /// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。 Refresh, } impl TaskKind { fn method(self) -> &'static str { match self { Self::Sync => RPC_METHOD_RESOURCE_SYNC, Self::Verify => RPC_METHOD_RESOURCE_VERIFY, Self::Refresh => RPC_METHOD_CATALOG_REFRESH, } } /// 由 daemon 基准配置派生该任务的实际同步配置。 fn build_config(self, base: &OfficialUpdateConfig, force: bool) -> OfficialUpdateConfig { let mut config = base.clone(); match self { Self::Sync => { config.dry_run = false; config.force = config.force || force; } Self::Verify => { config.dry_run = true; config.plan = true; config.audit_local = true; config.repair = false; config.force = false; } Self::Refresh => { config.dry_run = true; config.plan = true; config.audit_local = false; config.repair = false; config.force = force; } } config } } /// 请求取消任务的结果。 enum CancelOutcome { Requested, AlreadyFinished, NotFound, } /// 单个任务的可轮询记录。 #[derive(Debug, Clone, Serialize)] struct TaskRecord { id: String, kind: &'static str, /// `queued` | `running` | `succeeded` | `failed` | `cancelled`。 status: &'static str, #[serde(skip_serializing_if = "Option::is_none")] stage: Option, #[serde(skip_serializing_if = "Option::is_none")] message: Option, created_at: u64, updated_at: u64, #[serde(skip_serializing_if = "Option::is_none")] started_at: Option, #[serde(skip_serializing_if = "Option::is_none")] finished_at: Option, #[serde(skip_serializing_if = "Option::is_none")] error: Option, #[serde(skip_serializing_if = "Option::is_none")] result: Option, /// 取消标志,worker 的 should_cancel 检查它;不参与序列化。 #[serde(skip)] cancel: Arc, /// 进度日志(有界),经 task.logs 返回;不参与 task.status 序列化。 #[serde(skip)] log: Vec, } impl TaskRecord { fn is_finished(&self) -> bool { matches!(self.status, "succeeded" | "failed" | "cancelled") } } struct TaskStore { tasks: HashMap, order: Vec, seq: u64, /// 任务历史持久化文件路径;`None` 表示纯内存(测试等非 daemon 场景)。 persist_path: Option, } /// daemon 任务历史持久化文件名(位于 state dir 内,`0600` 原子写)。 const TASKS_FILE_NAME: &str = "bat-tasks.json"; /// 任务历史文件结构版本。 const TASKS_FILE_VERSION: u32 = 1; /// 任务历史文件的持久化形态(版本化;daemon 重启后恢复任务历史用)。 #[derive(Debug, Serialize, Deserialize)] struct PersistedTaskFile { version: u32, /// 任务 ID 序号计数器;恢复它避免 pid 复用时新任务与历史任务撞 ID。 seq: u64, tasks: Vec, } #[derive(Debug, Serialize, Deserialize)] struct PersistedTaskRecord { id: String, kind: String, status: String, #[serde(default, skip_serializing_if = "Option::is_none")] stage: Option, #[serde(default, skip_serializing_if = "Option::is_none")] message: Option, created_at: u64, updated_at: u64, #[serde(default, skip_serializing_if = "Option::is_none")] started_at: Option, #[serde(default, skip_serializing_if = "Option::is_none")] finished_at: Option, /// `ApiError` 的序列化形态(code/kind/domain/location/message/retryable)。 #[serde(default, skip_serializing_if = "Option::is_none")] error: Option, #[serde(default, skip_serializing_if = "Option::is_none")] result: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] log: Vec, } /// 把持久化的任务类型映射回静态字符串;未识别(如未来版本新增)返回 `None`。 fn task_kind_static(kind: &str) -> Option<&'static str> { match kind { RPC_METHOD_RESOURCE_SYNC => Some(RPC_METHOD_RESOURCE_SYNC), RPC_METHOD_RESOURCE_VERIFY => Some(RPC_METHOD_RESOURCE_VERIFY), RPC_METHOD_CATALOG_REFRESH => Some(RPC_METHOD_CATALOG_REFRESH), _ => None, } } /// 把持久化的任务状态映射回静态字符串;未识别返回 `None`。 fn task_status_static(status: &str) -> Option<&'static str> { match status { "queued" => Some("queued"), "running" => Some("running"), "succeeded" => Some("succeeded"), "failed" => Some("failed"), "cancelled" => Some("cancelled"), _ => None, } } impl PersistedTaskRecord { fn from_record(record: &TaskRecord) -> Self { Self { id: record.id.clone(), kind: record.kind.to_string(), status: record.status.to_string(), stage: record.stage.clone(), message: record.message.clone(), created_at: record.created_at, updated_at: record.updated_at, started_at: record.started_at, finished_at: record.finished_at, error: record .error .as_ref() .and_then(|error| serde_json::to_value(error).ok()), result: record.result.clone(), log: record.log.clone(), } } /// 还原为内存任务记录;kind/status 未识别时返回 `None`(调用方计数跳过)。 fn into_record(self) -> Option { let kind = task_kind_static(&self.kind)?; let status = task_status_static(&self.status)?; // 错误从序列化形态还原:code 经码表反查(未登记回退 internal), // location 固定为任务执行器(当前全部任务错误的唯一来源)。 let error = self.error.as_ref().map(|value| { let code = value .get("code") .and_then(serde_json::Value::as_str) .and_then(ErrorCode::from_id) .unwrap_or(ErrorCode::INTERNAL); let message = value .get("message") .and_then(serde_json::Value::as_str) .unwrap_or("<持久化错误信息缺失>") .to_string(); ApiError::new(code, "task.executor", message) }); Some(TaskRecord { id: self.id, kind, status, stage: self.stage, message: self.message, created_at: self.created_at, updated_at: self.updated_at, started_at: self.started_at, finished_at: self.finished_at, error, result: self.result, cancel: Arc::new(AtomicBool::new(false)), log: self.log, }) } } /// 读取任务历史文件。文件缺失返回 `Ok(None)`;symlink、解析失败或版本不支持返回 `Err`。 fn load_persisted_tasks(path: &Path) -> Result, String> { let Some(bytes) = read_file_no_symlink(path, "任务历史")? else { return Ok(None); }; let file: PersistedTaskFile = serde_json::from_slice(&bytes) .map_err(|error| format!("解析任务历史失败 {}:{error}", path.display()))?; if file.version != TASKS_FILE_VERSION { return Err(format!( "不支持的任务历史版本 {},文件 {}", file.version, path.display() )); } Ok(Some(file)) } /// 任务注册表句柄:包住内存存储,供 RPC handler 与 worker 共享。 /// /// 通过方法访问(而非直接摸内部 map),便于将来换成 Redis 等持久化后端。 #[derive(Clone)] struct TaskRegistry { inner: Arc>, } impl TaskRegistry { /// 纯内存注册表(无持久化);生产 daemon 走 [`Self::with_persistence`]。 #[cfg(test)] fn new() -> Self { Self { inner: Arc::new(Mutex::new(TaskStore { tasks: HashMap::new(), order: Vec::new(), seq: 0, persist_path: None, })), } } /// 从 state dir 恢复任务历史并启用持久化。 /// /// 中断时仍处于 queued/running 的任务标记为 `failed`(`TASK_INTERRUPTED`); /// 文件缺失按空历史处理;文件损坏或版本不支持时改名 `.corrupt` 留证并从 /// 空历史开始。返回注册表与恢复摘要(供 daemon 日志记录)。 fn with_persistence(state_dir: &Path) -> (Self, String) { let path = state_dir.join(TASKS_FILE_NAME); let now = unix_seconds_now(); let mut seq = 0; let mut tasks = HashMap::new(); let mut order = Vec::new(); let summary = match load_persisted_tasks(&path) { Ok(None) => "无历史任务文件,从空任务历史开始".to_string(), Ok(Some(file)) => { seq = file.seq; let total = file.tasks.len(); let mut interrupted = 0usize; let mut skipped = 0usize; for persisted in file.tasks { let Some(mut record) = persisted.into_record() else { skipped += 1; continue; }; if !record.is_finished() { interrupted += 1; record.status = "failed"; record.finished_at = Some(now); record.updated_at = now; record.error = Some(ApiError::new( ErrorCode::TASK_INTERRUPTED, "task.executor", "daemon 停止/重启导致任务中断", )); record .log .push("[daemon] 任务因 daemon 停止/重启而中断".to_string()); } if tasks.insert(record.id.clone(), record.clone()).is_none() { order.push(record.id); } else { skipped += 1; } } format!("恢复任务历史 {total} 条(标记中断 {interrupted} 条,跳过无法识别 {skipped} 条)") } Err(error) => { // 保留损坏文件供诊断(改名而非覆盖),从空历史开始。 let corrupt = path.with_extension("json.corrupt"); if fs::rename(&path, &corrupt).is_ok() { format!( "任务历史不可用({error});原文件已改名保留为 {}", corrupt.display() ) } else { format!("任务历史不可用({error});且无法改名保留原文件") } } }; let registry = Self { inner: Arc::new(Mutex::new(TaskStore { tasks, order, seq, persist_path: Some(path), })), }; // 把中断标记(或空历史)立即写回,保证文件与内存视图一致。 registry.lock().persist(); (registry, summary) } fn lock(&self) -> std::sync::MutexGuard<'_, TaskStore> { self.inner .lock() .unwrap_or_else(|poison| poison.into_inner()) } /// 创建 queued 任务并返回 task_id。 fn create(&self, kind: TaskKind) -> String { let now = unix_seconds_now(); let mut store = self.lock(); store.seq += 1; let id = format!("task-{}-{}", std::process::id(), store.seq); let record = TaskRecord { id: id.clone(), kind: kind.method(), status: "queued", stage: None, message: None, created_at: now, updated_at: now, started_at: None, finished_at: None, error: None, result: None, cancel: Arc::new(AtomicBool::new(false)), log: Vec::new(), }; store.tasks.insert(id.clone(), record); store.order.push(id.clone()); store.prune(); store.persist(); id } fn update(&self, id: &str, update: F) { let mut store = self.lock(); let mut status_changed = false; if let Some(record) = store.tasks.get_mut(id) { let previous_status = record.status; update(record); record.updated_at = unix_seconds_now(); status_changed = record.status != previous_status; } // 只在生命周期转换时落盘;stage/message/log 的高频进度更新以内存为准, // 随下一次转换一起写入(避免每个进度事件一次磁盘写)。 if status_changed { store.persist(); } } fn get(&self, id: &str) -> Option { self.lock().tasks.get(id).cloned() } /// 返回任务的取消标志(与 worker 共享同一 Arc)。 fn cancel_flag(&self, id: &str) -> Option> { self.lock() .tasks .get(id) .map(|record| Arc::clone(&record.cancel)) } /// 追加一行进度日志,超出上限时丢弃最旧的。 fn append_log(&self, id: &str, line: String) { let mut store = self.lock(); if let Some(record) = store.tasks.get_mut(id) { record.log.push(line); if record.log.len() > MAX_TASK_LOG_LINES { let overflow = record.log.len() - MAX_TASK_LOG_LINES; record.log.drain(0..overflow); } } } /// 返回任务的进度日志。 fn logs(&self, id: &str) -> Option> { self.lock().tasks.get(id).map(|record| record.log.clone()) } /// 请求取消任务:未结束的置取消标志,已结束的原样返回,不存在返回 NotFound。 fn request_cancel(&self, id: &str) -> CancelOutcome { let store = self.lock(); match store.tasks.get(id) { None => CancelOutcome::NotFound, Some(record) if record.is_finished() => CancelOutcome::AlreadyFinished, Some(record) => { record.cancel.store(true, Ordering::Relaxed); CancelOutcome::Requested } } } /// 返回全部任务,最新创建的在前。 fn list(&self) -> Vec { let store = self.lock(); store .order .iter() .rev() .filter_map(|id| store.tasks.get(id).cloned()) .collect() } } impl TaskStore { /// 把当前任务历史落盘(`0600` 原子写、不跟随 symlink)。 /// /// 持久化未启用时为 no-op;写失败只记 stderr(进 daemon 日志), /// 不让持久化故障拖垮任务执行本身。 fn persist(&self) { let Some(path) = &self.persist_path else { return; }; let file = PersistedTaskFile { version: TASKS_FILE_VERSION, seq: self.seq, tasks: self .order .iter() .filter_map(|id| self.tasks.get(id)) .map(PersistedTaskRecord::from_record) .collect(), }; match serde_json::to_vec_pretty(&file) { Ok(bytes) => { if let Err(error) = write_file_atomic(path, &bytes, PRIVATE_FILE_MODE, "任务历史") { eprintln!("[daemon] 任务历史落盘失败:{error}"); } } Err(error) => eprintln!("[daemon] 任务历史序列化失败:{error}"), } } /// 裁剪最旧的已结束任务,把内存占用控制在上限内;运行中/排队中的任务不裁剪。 fn prune(&mut self) { while self.order.len() > MAX_RETAINED_TASKS { let Some(position) = self.order.iter().position(|id| { self.tasks .get(id) .map(TaskRecord::is_finished) .unwrap_or(true) }) else { break; }; let id = self.order.remove(position); self.tasks.remove(&id); } } } /// 提交给任务 worker 的作业(配置已按任务类型派生完毕)。 struct TaskJob { id: String, config: OfficialUpdateConfig, /// 与任务记录共享的取消标志。 cancel: Arc, } /// daemon 任务上下文:RPC handler 借它创建任务、入队和读取。 #[derive(Clone)] struct DaemonTaskContext { registry: TaskRegistry, queue: mpsc::Sender, base_config: OfficialUpdateConfig, } /// 任务 worker:单线程 FIFO 消费任务队列,串行执行官方同步/校验。 /// /// 每个任务执行前获取进程内 `sync_lock`,与 watch 循环互斥(等待而非撞文件锁失败); /// 进度写入任务记录;`should_cancel` 接 daemon 停止标志,停机时中止在途任务。 fn run_task_worker( receiver: mpsc::Receiver, registry: TaskRegistry, sync_lock: Arc>, control: DaemonControl, ) { let service = OfficialUpdateService::new(); for job in receiver { registry.update(&job.id, |record| { record.status = "running"; record.started_at = Some(unix_seconds_now()); }); let cancel = Arc::clone(&job.cancel); let run_result = { let _sync_guard = sync_lock .lock() .unwrap_or_else(|poison| poison.into_inner()); let progress_registry = registry.clone(); let progress_id = job.id.clone(); let cancel_check = Arc::clone(&cancel); let stop_control = Arc::clone(&control); service.run_with_progress_and_cancellation( &job.config, |event| { progress_registry .append_log(&progress_id, format!("[{}] {}", event.stage, event.message)); progress_registry.update(&progress_id, |record| { record.stage = Some(event.stage.to_string()); record.message = Some(event.message.clone()); }); }, || { cancel_check.load(Ordering::Relaxed) || daemon_control_stop_requested(Some(&stop_control)) }, ) }; match run_result { Ok(report) => registry.update(&job.id, |record| { record.status = "succeeded"; record.finished_at = Some(unix_seconds_now()); record.result = serde_json::to_value(&report).ok(); }), Err(error) => { let cancelled = cancel.load(Ordering::Relaxed); // 下载失败携带类型化 DownloadError(含准确网络域码);其余归 internal。 let code = error .downcast_ref::() .map(bat_infrastructure::DownloadError::code) .unwrap_or(ErrorCode::INTERNAL); registry.update(&job.id, |record| { record.finished_at = Some(unix_seconds_now()); if cancelled { record.status = "cancelled"; record.error = Some(ApiError::new( ErrorCode::INTERNAL, "task.executor", "任务已取消", )); } else { record.status = "failed"; record.error = Some(ApiError::new(code, "task.executor", error.to_string())); } }); } } } } /// 把 `bat.*` 兼容别名解析为规范的 `daemon.*` 方法名;其余原样返回。 fn canonical_rpc_method(method: &str) -> &str { match method { "bat.status" => RPC_METHOD_STATUS, "bat.stop" => RPC_METHOD_STOP, "bat.reload" => RPC_METHOD_RELOAD, "bat.refresh" => RPC_METHOD_REFRESH, "bat.logs" => RPC_METHOD_LOGS, other => other, } } /// 判断方法是否属于已规划但尚未实现的命名空间/动作(返回 not_implemented 而非 unknown)。 fn is_pending_rpc_method(method: &str) -> bool { // task.create:任务统一由 resource.sync / resource.verify / catalog.refresh // 等语义方法创建,通用创建接口暂不开放。 // resource.repair:引擎尚无独立修复模式(sync 自带审计+重下)。 // patch.* / unityfs.*:被 bat-patch / bat-assetbundle 引擎阻塞。 matches!(method, "task.create" | "resource.repair") || method.starts_with("patch.") || method.starts_with("unityfs.") } /// RPC 应用层统一 envelope,装入 JSON-RPC 2.0 的 `result`。 /// /// 所有响应都带 `ok`/`status`/`data`/`error`/`request_id`;传输层错误(请求解析失败) /// 仍走 JSON-RPC 顶层 `error`。 #[derive(Debug, Serialize)] struct RpcEnvelope { ok: bool, status: &'static str, #[serde(skip_serializing_if = "Option::is_none")] data: Option, #[serde(skip_serializing_if = "Option::is_none")] error: Option, request_id: String, } /// 生成进程内唯一的 request_id(`req--`)。 fn next_request_id() -> String { use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(1); format!( "req-{}-{}", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed) ) } fn rpc_envelope_ok( request_id: String, status: &'static str, data: serde_json::Value, ) -> RpcEnvelope { RpcEnvelope { ok: true, status, data: Some(data), error: None, request_id, } } fn rpc_envelope_error(request_id: String, error: ApiError) -> RpcEnvelope { RpcEnvelope { ok: false, status: "error", data: None, error: Some(error), request_id, } } /// 把内部 `anyhow::Result` 转成 envelope;成功为 ok,失败归为内部错误码。 fn rpc_envelope_from_result( request_id: String, location: &'static str, result: anyhow::Result, ) -> RpcEnvelope { match result { Ok(data) => rpc_envelope_ok(request_id, "ok", data), Err(error) => rpc_envelope_error( request_id, ApiError::new(ErrorCode::INTERNAL, location, error.to_string()), ), } } 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, tasks: DaemonTaskContext, ) -> 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 tasks = tasks.clone(); let _ = thread::Builder::new() .name("bat-daemon-rpc-client".to_string()) .spawn(move || { handle_daemon_rpc_client(stream, state_dir, control, tasks) }); } 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, _tasks: DaemonTaskContext, ) -> anyhow::Result { Err(anyhow::anyhow!("daemon RPC 目前只支持 Unix/Linux 平台")) } #[cfg(unix)] fn handle_daemon_rpc_client( mut stream: UnixStream, state_dir: PathBuf, control: DaemonControl, tasks: DaemonTaskContext, ) { 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, &tasks) } 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, tasks: &DaemonTaskContext, ) -> JsonRpcResponse { let id = request.id.clone(); let request_id = next_request_id(); let envelope = dispatch_rpc_method(&request, state_dir, control, tasks, request_id); match serde_json::to_value(&envelope) { Ok(value) => json_rpc_result(id, value), Err(error) => json_rpc_error(id, -32603, error.to_string()), } } /// 命名空间分发:把请求路由到对应 handler,统一返回应用层 envelope。 fn dispatch_rpc_method( request: &JsonRpcRequest, state_dir: &Path, control: &DaemonControl, tasks: &DaemonTaskContext, request_id: String, ) -> RpcEnvelope { match canonical_rpc_method(&request.method) { RPC_METHOD_STATUS => rpc_envelope_from_result( request_id, "daemon.status", build_daemon_status_report(state_dir) .and_then(|report| serde_json::to_value(report).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 rpc_envelope_error( request_id, ApiError::new( ErrorCode::RPC_INVALID_PARAMS, "daemon.logs", error.to_string(), ), ) } }; rpc_envelope_from_result( request_id, "daemon.logs", build_logs_report(state_dir, tail) .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"); rpc_envelope_ok( request_id, "accepted", rpc_ack_value("stop", "后台停止请求已发送", state_dir, None), ) } RPC_METHOD_RELOAD => { daemon_control_request_reload(control); rpc_envelope_ok( request_id, "accepted", rpc_ack_value( "reload", "后台重新加载请求已发送;空闲时会立即唤醒,忙碌时会在当前轮结束后重新执行自动发现和强制刷新", state_dir, Some(true), ), ) } RPC_METHOD_REFRESH => { let force = rpc_bool_param(request.params.as_ref(), "force").unwrap_or(false); daemon_control_request_refresh(control, force); let message = if force { "后台强制刷新请求已发送;空闲时会立即唤醒,忙碌时会在当前轮结束后执行" } else { "后台刷新检查请求已发送;空闲时会立即唤醒,忙碌时会在当前轮结束后执行" }; rpc_envelope_ok( request_id, "accepted", rpc_ack_value("refresh", message, state_dir, Some(force)), ) } RPC_METHOD_RESOURCE_STATE => rpc_envelope_from_result( request_id, "resource.state", build_resource_state_report(state_dir), ), RPC_METHOD_RESOURCE_SYNC => { let force = rpc_bool_param(request.params.as_ref(), "force").unwrap_or(false); enqueue_task_envelope(tasks, TaskKind::Sync, force, request_id) } RPC_METHOD_RESOURCE_VERIFY => { enqueue_task_envelope(tasks, TaskKind::Verify, false, request_id) } RPC_METHOD_RESOURCE_MANIFEST => { let (offset, limit) = match rpc_page_params(request.params.as_ref()) { Ok(page) => page, Err(error) => { return rpc_envelope_error( request_id, ApiError::new( ErrorCode::RPC_INVALID_PARAMS, "resource.manifest", error.to_string(), ), ) } }; rpc_envelope_from_result( request_id, "resource.manifest", build_resource_manifest_report(state_dir, offset, limit), ) } RPC_METHOD_CATALOG_STATUS => rpc_envelope_from_result( request_id, "catalog.status", build_catalog_status_report(state_dir), ), RPC_METHOD_CATALOG_VERSIONS => rpc_envelope_from_result( request_id, "catalog.versions", build_catalog_versions_report(state_dir), ), RPC_METHOD_CATALOG_DIFF => rpc_envelope_from_result( request_id, "catalog.diff", build_catalog_diff_report(state_dir), ), RPC_METHOD_CATALOG_REFRESH => { let force = rpc_bool_param(request.params.as_ref(), "force").unwrap_or(false); enqueue_task_envelope(tasks, TaskKind::Refresh, force, request_id) } RPC_METHOD_TASK_STATUS => { let task_id = rpc_task_id_param(request); match tasks.registry.get(task_id) { Some(record) => rpc_envelope_from_result( request_id, "task.status", serde_json::to_value(record).map_err(anyhow::Error::from), ), None => rpc_envelope_error( request_id, ApiError::new( ErrorCode::TASK_NOT_FOUND, "task.status", format!("任务不存在:{task_id}"), ), ), } } RPC_METHOD_TASK_LIST => rpc_envelope_from_result( request_id, "task.list", serde_json::to_value(serde_json::json!({ "tasks": tasks.registry.list() })) .map_err(anyhow::Error::from), ), RPC_METHOD_TASK_CANCEL => { let task_id = rpc_task_id_param(request); match tasks.registry.request_cancel(task_id) { CancelOutcome::Requested => rpc_envelope_ok( request_id, "accepted", serde_json::json!({ "task_id": task_id, "cancel_requested": true }), ), CancelOutcome::AlreadyFinished => rpc_envelope_ok( request_id, "ok", serde_json::json!({ "task_id": task_id, "cancel_requested": false, "note": "任务已结束" }), ), CancelOutcome::NotFound => rpc_envelope_error( request_id, ApiError::new( ErrorCode::TASK_NOT_FOUND, "task.cancel", format!("任务不存在:{task_id}"), ), ), } } RPC_METHOD_TASK_LOGS => { let task_id = rpc_task_id_param(request); match tasks.registry.logs(task_id) { Some(lines) => rpc_envelope_from_result( request_id, "task.logs", serde_json::to_value(serde_json::json!({ "task_id": task_id, "lines": lines })) .map_err(anyhow::Error::from), ), None => rpc_envelope_error( request_id, ApiError::new( ErrorCode::TASK_NOT_FOUND, "task.logs", format!("任务不存在:{task_id}"), ), ), } } pending if is_pending_rpc_method(pending) => rpc_envelope_error( request_id, ApiError::new( ErrorCode::RPC_NOT_IMPLEMENTED, "rpc.dispatch", format!("方法尚未实现:{pending}"), ), ), unknown => rpc_envelope_error( request_id, ApiError::new( ErrorCode::RPC_UNKNOWN_METHOD, "rpc.dispatch", format!("未知 RPC 方法:{unknown}"), ), ), } } /// 创建任务、入队,返回 `accepted` + task_id 的 envelope。 fn enqueue_task_envelope( tasks: &DaemonTaskContext, kind: TaskKind, force: bool, request_id: String, ) -> RpcEnvelope { let config = kind.build_config(&tasks.base_config, force); let task_id = tasks.registry.create(kind); let cancel = tasks .registry .cancel_flag(&task_id) .unwrap_or_else(|| Arc::new(AtomicBool::new(false))); let job = TaskJob { id: task_id.clone(), config, cancel, }; if tasks.queue.send(job).is_err() { // worker 已退出:把该任务标记为失败并返回错误。 tasks.registry.update(&task_id, |record| { record.status = "failed"; record.finished_at = Some(unix_seconds_now()); record.error = Some(ApiError::new( ErrorCode::INTERNAL, "task.enqueue", "任务执行器不可用", )); }); return rpc_envelope_error( request_id, ApiError::new(ErrorCode::INTERNAL, "task.enqueue", "任务执行器不可用"), ); } rpc_envelope_ok( request_id, "accepted", serde_json::json!({ "task_id": task_id, "kind": kind.method() }), ) } fn rpc_ack_value( command: &'static str, message: &'static str, state_dir: &Path, force: Option, ) -> serde_json::Value { serde_json::to_value(DaemonRpcAck { command, status: "accepted", message, state_dir: state_dir.to_path_buf(), socket_path: daemon_socket_path(state_dir), force, }) .unwrap_or(serde_json::Value::Null) } /// 构建 `resource.state` 数据:资源发布根、版本状态、上次同步结果。 /// 读取 daemon 状态文件与资源根目录的版本状态(resource/catalog 只读查询共用)。 fn read_daemon_resource_state( state_dir: &Path, ) -> anyhow::Result<(Option, Option)> { let status_file = read_daemon_status_file(&daemon_status_path(state_dir))?; let version_state = status_file .as_ref() .map(|status| { status .resource_output_root .join("official-version-state.json") }) .and_then(|path| read_version_state(&path).ok().flatten()); Ok((status_file, version_state)) } fn build_resource_state_report(state_dir: &Path) -> anyhow::Result { let (status_file, version_state) = read_daemon_resource_state(state_dir)?; Ok(serde_json::json!({ "resource_output_root": status_file .as_ref() .map(|status| status.resource_output_root.clone()), "version_state": version_state, "last_update_status": status_file .as_ref() .and_then(|status| status.last_update_status.clone()), "last_success_unix_seconds": status_file .as_ref() .and_then(|status| status.last_success_unix_seconds), })) } /// `catalog.status`:当前已发布版本的 catalog 概览(读取其 snapshot)。 /// /// 没有已发布版本或 snapshot 不可读时返回 `available: false`(正常状态, /// 不视为错误,便于 Go 层直接分支)。 fn build_catalog_status_report(state_dir: &Path) -> anyhow::Result { let (_, version_state) = read_daemon_resource_state(state_dir)?; let current = version_state .as_ref() .and_then(|state| state.current_completed_version.as_ref()); let snapshot = current.and_then(|record| read_snapshot(&record.snapshot_path).ok().flatten()); let (Some(record), Some(snapshot)) = (current, snapshot) else { return Ok(serde_json::json!({ "available": false })); }; let official_seed_hash_marker_count = snapshot .endpoint_markers .iter() .filter(|marker| marker.role == OfficialEndpointMarkerRole::OfficialSeedHash) .count(); Ok(serde_json::json!({ "available": true, "version": { "id": record.id, "completed_unix_seconds": record.completed_unix_seconds, "resource_root": record.resource_root, }, "app_version": snapshot.app_version, "bundle_version": snapshot.bundle_version, "connection_group_name": snapshot.connection_group_name, "addressables_root": snapshot.addressables_root, "endpoint_count": snapshot.endpoints.len(), "endpoint_marker_count": snapshot.endpoint_markers.len(), "official_seed_hash_marker_count": official_seed_hash_marker_count, "addressables_catalog_marker_count": snapshot.addressables_marker_checked_count(), "launcher_metadata": snapshot.launcher_metadata, "game_main_config": snapshot.game_main_config_bootstrap, "snapshot_version": snapshot.snapshot_version, })) } /// `catalog.versions`:版本历史(当前/进行中/上一个可用/失败记录)。 fn build_catalog_versions_report(state_dir: &Path) -> anyhow::Result { let (status_file, version_state) = read_daemon_resource_state(state_dir)?; let Some(state) = version_state else { return Ok(serde_json::json!({ "available": false })); }; Ok(serde_json::json!({ "available": true, "resource_output_root": status_file.map(|status| status.resource_output_root), "current": state.current_completed_version, "in_progress": state.in_progress_version, "previous": state.previous_available_version, "failed": state.failed_versions, "updated_unix_seconds": state.updated_unix_seconds, })) } /// `catalog.diff`:当前已发布 snapshot 相对上一个可用版本的差异。 /// /// 没有上一个版本(或其 snapshot 不可读,见 `previous_snapshot_missing`)时 /// 按首次观察处理(`base_delta.is_initial = true`)。 fn build_catalog_diff_report(state_dir: &Path) -> anyhow::Result { let (_, version_state) = read_daemon_resource_state(state_dir)?; let current_record = version_state .as_ref() .and_then(|state| state.current_completed_version.as_ref()); let current_snapshot = current_record.and_then(|record| read_snapshot(&record.snapshot_path).ok().flatten()); let (Some(current_record), Some(current_snapshot)) = (current_record, current_snapshot) else { return Ok(serde_json::json!({ "available": false })); }; let previous_record = version_state .as_ref() .and_then(|state| state.previous_available_version.as_ref()); let previous_snapshot = previous_record.and_then(|record| read_snapshot(&record.snapshot_path).ok().flatten()); let current_base = current_snapshot.base_snapshot(); let previous_base = previous_snapshot .as_ref() .map(OfficialUpdateSnapshot::base_snapshot); let base_delta = current_base.diff(previous_base.as_ref()); let extended_delta = diff_extended_snapshot(¤t_snapshot, previous_snapshot.as_ref()); let changed_urls = changed_endpoint_urls(&base_delta); Ok(serde_json::json!({ "available": true, "current_version_id": current_record.id, "previous_version_id": previous_record.map(|record| record.id.clone()), "previous_snapshot_missing": previous_record.is_some() && previous_snapshot.is_none(), "base_delta": base_delta, "extended_delta": extended_delta, "changed_endpoint_urls": changed_urls, })) } /// `resource.manifest`:当前版本下载 manifest 的分页查询。 fn build_resource_manifest_report( state_dir: &Path, offset: usize, limit: usize, ) -> anyhow::Result { let (_, version_state) = read_daemon_resource_state(state_dir)?; let current = version_state .as_ref() .and_then(|state| state.current_completed_version.as_ref()); let Some(record) = current else { return Ok(serde_json::json!({ "available": false })); }; let manifest = read_download_manifest_at(&record.resource_root).map_err(anyhow::Error::msg)?; let Some(manifest) = manifest else { return Ok(serde_json::json!({ "available": false, "resource_root": record.resource_root, })); }; let total_entries = manifest.entries.len(); // BTreeMap 按 URL 有序迭代,分页结果稳定。 let entries: Vec<_> = manifest .entries .values() .skip(offset) .take(limit) .cloned() .collect(); Ok(serde_json::json!({ "available": true, "resource_root": record.resource_root, "manifest_version": manifest.version, "total_entries": total_entries, "offset": offset, "limit": limit, "entries": entries, })) } #[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_task_id_param(request: &JsonRpcRequest) -> &str { request .params .as_ref() .and_then(|params| params.get("task_id")) .and_then(serde_json::Value::as_str) .unwrap_or_default() } 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) } /// 分页参数:`offset` 默认 0;`limit` 默认 100,范围 1..=1000。 fn rpc_page_params(params: Option<&serde_json::Value>) -> anyhow::Result<(usize, usize)> { let offset = params .and_then(|params| params.get("offset")) .and_then(serde_json::Value::as_u64) .unwrap_or(0); let limit = params .and_then(|params| params.get("limit")) .and_then(serde_json::Value::as_u64) .unwrap_or(100); let offset = usize::try_from(offset)?; let limit = usize::try_from(limit)?; if limit == 0 || limit > 1000 { return Err(anyhow::anyhow!("limit 必须在 1..=1000 范围内")); } Ok((offset, limit)) } 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 )); } // 解包应用层 envelope:检查 ok,成功返回 data,失败把 envelope.error 转为 Err。 let envelope = response .result .ok_or_else(|| anyhow::anyhow!("后台 RPC {method} 未返回 result"))?; let ok = envelope .get("ok") .and_then(serde_json::Value::as_bool) .unwrap_or(false); if !ok { let error = envelope.get("error"); let code = error .and_then(|error| error.get("code")) .and_then(serde_json::Value::as_str) .unwrap_or(""); let message = error .and_then(|error| error.get("message")) .and_then(serde_json::Value::as_str) .unwrap_or("未知错误"); return Err(anyhow::anyhow!( "后台 RPC {method} 失败:[{code}] {message}" )); } Ok(envelope .get("data") .cloned() .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(()); } } // 非 RPC 路径:stop_daemon_inner 内部会通过 classify_pid_lock_file 校验并分类 // PID 文件(含 symlink 拒绝),无需在此重复读取。 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, &options.env_baseline_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); // ok 与 message 从同一次解析结果派生,避免“ok=false 却提示可解析”的自相矛盾。 let daemon_status_result = read_daemon_status_file(&status_path); checks.push(DoctorCheck { name: "daemon_status", ok: daemon_status_result.is_ok(), message: match &daemon_status_result { Ok(Some(_)) => format!("后台状态文件可解析:{}", status_path.display()), Ok(None) => "后台状态文件尚未创建".to_string(), Err(error) => format!("后台状态文件无法解析:{};{error}", status_path.display()), }, }); 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)?)) } /// 串行化 `bat-status.json` 的读-改-写。 /// /// watch 循环线程(`update_daemon_status`/`update_daemon_progress`)与 RPC 处理线程 /// (`update_daemon_state_only`)都会更新状态文件;单次写虽原子,但“读→改字段→写回” /// 整体非原子,无此锁会因 last-writer-wins 丢失并发线程的字段更新。 static DAEMON_STATUS_FILE_LOCK: Mutex<()> = Mutex::new(()); fn lock_daemon_status_file() -> std::sync::MutexGuard<'static, ()> { DAEMON_STATUS_FILE_LOCK .lock() .unwrap_or_else(|poison| poison.into_inner()) } 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 _guard = lock_daemon_status_file(); 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 _guard = lock_daemon_status_file(); 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 _guard = lock_daemon_status_file(); 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(state_dir: &Path) -> PathBuf { state_dir.join(DAEMON_PID_FILE) } fn daemon_status_path(state_dir: &Path) -> PathBuf { state_dir.join(DAEMON_STATUS_FILE) } fn daemon_log_path(state_dir: &Path) -> PathBuf { state_dir.join(DAEMON_LOG_FILE) } fn daemon_structured_log_path(state_dir: &Path) -> PathBuf { state_dir.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(state_dir: &Path) -> PathBuf { state_dir.join(DAEMON_SOCKET_FILE) } fn daemon_control_lock_path(state_dir: &Path) -> PathBuf { state_dir.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) } } // 非 Unix 平台无法用 kill(pid, 0) 探测进程存活;生产链路只支持 Linux。 // 这里保守返回 true(假定进程仍存活),避免误把可能仍在运行的 daemon 的 // PID/锁文件当作 stale 而回收——宁可要求手动清理,也不冒重复启动的风险。 #[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 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) } /// `.env` 配置文件名(位于 bat 二进制所在目录)。 const ENV_FILE_NAME: &str = ".env"; /// 设为 `1` 时完全跳过 `.env` 的生成与加载(测试与特殊部署场景用)。 const SKIP_ENV_FILE_VAR: &str = "BAT_SKIP_ENV_FILE"; /// 首次启动释放的 `.env` 配置模板。 const ENV_TEMPLATE: &str = r#"# BlueArchive Toolkit 配置文件(bat 首次启动自动生成) # # 直接运行 `bat`(无参数)时会按本文件配置启动。 # 优先级:命令行参数 > 进程环境变量 > 本文件 > 内置默认值。 # 布尔值支持 1/0/true/false/yes/no/on/off;井号开头为注释。 # 设 BAT_SKIP_ENV_FILE=1 可让 bat 完全忽略本文件。 # ---- 基本配置 ---- # 资源发布根目录(默认 ./bat-resources,相对当前工作目录) BAT_OUTPUT=./bat-resources # 自动发现 app-version / connection-group / server-info(无参启动建议保持 1) BAT_AUTO_DISCOVER=1 # 后台状态目录(bat.sock / 日志 / 任务历史等;默认 /tmp/bat-pid) #BAT_STATE_DIR=/tmp/bat-pid # 启动即进入常驻模式:watch(前台常驻)或 daemon(后台自托管)。 # 只对无子命令的 `bat` 生效;同时为 1 时 daemon 优先。 #BAT_WATCH=0 #BAT_DAEMON=0 # 正常检查间隔与失败重试间隔(秒) #BAT_INTERVAL_SECONDS=3600 #BAT_ERROR_RETRY_SECONDS=60 # 下载并发度(并行网络下载数,1~256;默认 4,对官方 CDN 礼貌) #BAT_DOWNLOAD_CONCURRENCY=4 # ---- 网络 ---- # 显式代理 URL(支持 http/https/socks4/socks4a/socks5/socks5h)。 # 不设则自动检测 HTTPS_PROXY / ALL_PROXY / HTTP_PROXY(也可写在本文件里)。 #BAT_PROXY=http://127.0.0.1:7897 # 设为 1 时强制直连(忽略一切代理配置) #BAT_NO_PROXY=0 # ---- 同步参数(通常保持自动发现,无需手动指定)---- #BAT_APP_VERSION= #BAT_CONNECTION_GROUP= #BAT_LAUNCHER_VERSION= # 逗号分隔:windows,android #BAT_PLATFORMS=windows,android #BAT_CURL=curl #BAT_UNZIP=unzip # ---- 输出 ---- # 设为 1 时输出机器可读 JSON(默认人类可读) #BAT_JSON=0 # 远端与本地一致时是否静默(watch/daemon 模式默认 1) #BAT_QUIET_UP_TO_DATE= # ---- Redis(预留,当前未接入)---- # 任务历史当前持久化在 /bat-tasks.json; # Redis 任务后端落地后以下配置才会生效。 #BAT_REDIS_URL=redis://127.0.0.1:6379 #BAT_REDIS_PASSWORD= "#; /// `.env` 引导:首次启动时在二进制所在目录释放配置模板,之后每次启动把其中的 /// 键加载为进程环境变量(不覆盖已存在的环境变量,保持"环境变量 > .env"优先级)。 /// /// 任何失败只在 stderr 警告、不中断启动——`.env` 是便利层,不是启动硬依赖。 fn bootstrap_env_file() { if env::var(SKIP_ENV_FILE_VAR).map(|value| value == "1") == Ok(true) { return; } let Ok(exe_path) = env::current_exe() else { return; }; let Some(exe_dir) = exe_path.parent() else { return; }; let path = exe_dir.join(ENV_FILE_NAME); if !path.exists() { match write_env_template(&path) { Ok(()) => eprintln!( "已生成配置模板 {}(编辑其中的 BAT_* 配置后,直接运行 `bat` 即可按 .env 启动)", path.display() ), Err(error) => { eprintln!("警告:生成 .env 配置模板失败 {}:{error}", path.display()); return; } } } match fs::read_to_string(&path) { Ok(content) => apply_env_file(&content), Err(error) => eprintln!("警告:读取 .env 失败 {}:{error}", path.display()), } } /// 以 `create_new` 原子创建模板文件,避免并发启动时互相覆盖;unix 下限制 `0600` /// 权限(`.env` 可能保存代理凭据等敏感配置)。 fn write_env_template(path: &Path) -> std::io::Result<()> { let mut open_options = OpenOptions::new(); open_options.write(true).create_new(true); #[cfg(unix)] open_options.mode(PRIVATE_FILE_MODE); let mut file = open_options.open(path)?; file.write_all(ENV_TEMPLATE.as_bytes()) } /// 解析 `.env` 内容,把进程环境里尚不存在的键设为环境变量。 fn apply_env_file(content: &str) { for (line_number, raw_line) in content.lines().enumerate() { let line = raw_line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let Some((key, value)) = parse_env_line(line) else { eprintln!( "警告:.env 第 {} 行无法解析,已忽略:{raw_line}", line_number + 1 ); continue; }; if env::var_os(&key).is_none() { env::set_var(&key, value); } } } /// 解析单行 `KEY=VALUE`。key 须为 `[A-Za-z_][A-Za-z0-9_]*`;值两侧的成对 /// 单/双引号会剥除。不支持 `export` 前缀和多行值。 fn parse_env_line(line: &str) -> Option<(String, String)> { let (key, value) = line.split_once('=')?; let key = key.trim(); let valid_key = !key.is_empty() && key.chars().enumerate().all(|(index, character)| { character == '_' || character.is_ascii_alphabetic() || (index > 0 && character.is_ascii_digit()) }); if !valid_key { return None; } let mut value = value.trim(); if value.len() >= 2 { let bytes = value.as_bytes(); let quoted = (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\''); if quoted { value = &value[1..value.len() - 1]; } } Some((key.to_string(), value.to_string())) } /// `.env`/环境变量提供的运行模式开关(延迟到命令确定后应用;值型配置 /// 由 [`apply_bat_env_overrides`] 直接写入 options)。 struct EnvModeOverrides { watch: bool, daemon: bool, } /// 把 `BAT_*` 环境变量作为配置默认值写入 options。 /// /// 不标记任何 `*_explicit`(命令行参数在其后解析、总是覆盖);非法值报错 /// 而非静默忽略,保证配置问题可诊断。 fn apply_bat_env_overrides( options: &mut CliOptions, env_lookup: &impl Fn(&str) -> Option, ) -> anyhow::Result { fn parse_env_bool(key: &str, value: &str) -> anyhow::Result { match value.to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Ok(true), "0" | "false" | "no" | "off" => Ok(false), other => Err(anyhow::anyhow!( "环境变量 {key} 的布尔值无效:{other}(支持 1/0/true/false/yes/no/on/off)" )), } } fn parse_env_seconds(key: &str, value: &str) -> anyhow::Result { let seconds = value .parse::() .map_err(|error| anyhow::anyhow!("环境变量 {key} 的秒数无效:{error}"))?; Ok(Duration::from_secs(seconds)) } // 空值视为未设置:模板里保留 `BAT_XXX=` 形式的空行不产生副作用。 let value = |key: &str| { env_lookup(key) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) }; if let Some(v) = value("BAT_OUTPUT") { options.config.output_root = PathBuf::from(v); } if let Some(v) = value("BAT_STATE_DIR") { options.state_dir = PathBuf::from(v); } if let Some(v) = value("BAT_AUTO_DISCOVER") { options.config.auto_discover = parse_env_bool("BAT_AUTO_DISCOVER", &v)?; } if let Some(v) = value("BAT_APP_VERSION") { options.config.app_version = Some(v); } if let Some(v) = value("BAT_CONNECTION_GROUP") { options.config.connection_group = Some(v); } if let Some(v) = value("BAT_LAUNCHER_VERSION") { options.config.launcher_version = v; } if let Some(v) = value("BAT_PLATFORMS") { options.config.platforms = Some(parse_platforms(&v).map_err(anyhow::Error::msg)?); } if let Some(v) = value("BAT_CURL") { options.config.curl_command = PathBuf::from(v); } if let Some(v) = value("BAT_UNZIP") { options.config.unzip_command = PathBuf::from(v); } if let Some(v) = value("BAT_DOWNLOAD_CONCURRENCY") { options.config.download_concurrency = parse_download_concurrency(&v)?; } if let Some(v) = value("BAT_PROXY") { options.config.curl_proxy = parse_proxy_config(&v)?; } if let Some(v) = value("BAT_NO_PROXY") { if parse_env_bool("BAT_NO_PROXY", &v)? { options.config.curl_proxy = CurlProxyConfig::disabled(); } } if let Some(v) = value("BAT_INTERVAL_SECONDS") { options.interval = parse_env_seconds("BAT_INTERVAL_SECONDS", &v)?; } if let Some(v) = value("BAT_ERROR_RETRY_SECONDS") { options.error_retry_interval = parse_env_seconds("BAT_ERROR_RETRY_SECONDS", &v)?; } if let Some(v) = value("BAT_JSON") { if parse_env_bool("BAT_JSON", &v)? { options.output_format = OutputFormat::Json; } } if let Some(v) = value("BAT_QUIET_UP_TO_DATE") { options.quiet_up_to_date = parse_env_bool("BAT_QUIET_UP_TO_DATE", &v)?; options.quiet_up_to_date_explicit = true; } let watch = match value("BAT_WATCH") { Some(v) => parse_env_bool("BAT_WATCH", &v)?, None => false, }; let daemon = match value("BAT_DAEMON") { Some(v) => parse_env_bool("BAT_DAEMON", &v)?, None => false, }; Ok(EnvModeOverrides { watch, daemon }) } fn parse_args() -> anyhow::Result { parse_args_with_env(env::args(), |key| env::var(key).ok()) } /// 测试入口:不读环境变量,解析结果只由参数决定。 #[cfg(test)] fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result { parse_args_with_env(raw_args, |_| None) } fn parse_args_with_env( raw_args: impl IntoIterator, env_lookup: impl Fn(&str) -> Option, ) -> anyhow::Result { let mut args = raw_args.into_iter(); let binary = args.next().unwrap_or_else(|| "bat".to_string()); let mut options = CliOptions::default(); // `BAT_*` 环境变量(含 .env 加载的)先作为默认值写入,不标记 explicit; // 命令行参数随后解析,逐字段覆盖。工具/代理的"非默认"判断以本基线为准, // 保证 status/stop/logs 在 .env 存在时不误判为显式传了同步参数。 let env_modes = apply_bat_env_overrides(&mut options, &env_lookup)?; options.env_baseline_config = options.config.clone(); let mut mode_flag_from_cli = false; 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)?); } "--download-concurrency" => { let value = next_option_value(&mut args, &flag)?; options.config.download_concurrency = parse_download_concurrency(&value)?; options.sync_option_explicit = true; } "--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; mode_flag_from_cli = true; } "--daemon" => { options.daemon = true; options.sync_option_explicit = true; mode_flag_from_cli = true; } "--daemon-child" => { options.daemon_child = true; options.watch = true; options.sync_option_explicit = true; mode_flag_from_cli = 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}")), } } // `BAT_WATCH` / `BAT_DAEMON` 只影响无子命令的 Run(无参启动场景); // 命令行显式选择了运行模式或 dry-run 时让位(命令行优先于 .env), // status/verify 等子命令不受其影响。daemon 优先于 watch(daemon 自带 watch)。 if matches!(options.command, CliCommand::Run) && !mode_flag_from_cli && !options.config.dry_run { if env_modes.daemon { options.daemon = true; } else if env_modes.watch { options.watch = true; } } 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, &options.env_baseline_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.env_baseline_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}")) } } /// 判断 curl/代理/unzip 是否偏离基线。基线是环境变量(含 .env)应用后的 /// 配置快照,因此只有命令行显式传入才算"非默认"。 fn tools_are_non_default(config: &OfficialUpdateConfig, baseline: &OfficialUpdateConfig) -> bool { config.curl_command != baseline.curl_command || config.curl_proxy != baseline.curl_proxy || config.unzip_command != baseline.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!(" --download-concurrency Parallel downloads, 1..=256 (default: 4)"); 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() } /// 解析下载并发度:正整数,钳制到 `1..=256`。 fn parse_download_concurrency(value: &str) -> anyhow::Result { let parsed = value .parse::() .map_err(|error| anyhow::anyhow!("下载并发度无效:{error}"))?; if parsed == 0 { return Err(anyhow::anyhow!("下载并发度必须大于 0")); } Ok(parsed.min(256)) } 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())) } fn parse_with_env(values: &[&str], env: &[(&str, &str)]) -> anyhow::Result { let map: HashMap = env .iter() .map(|(key, value)| (key.to_string(), value.to_string())) .collect(); parse_args_with_env(values.iter().map(|value| value.to_string()), move |key| { map.get(key).cloned() }) } #[test] fn env_defaults_apply_and_cli_overrides() { let options = parse_with_env( &["bat"], &[ ("BAT_OUTPUT", "/srv/bat"), ("BAT_AUTO_DISCOVER", "1"), ("BAT_STATE_DIR", "/srv/state"), ("BAT_INTERVAL_SECONDS", "120"), ], ) .unwrap(); assert_eq!(options.config.output_root, PathBuf::from("/srv/bat")); assert!(options.config.auto_discover); assert_eq!(options.state_dir, PathBuf::from("/srv/state")); assert_eq!(options.interval, Duration::from_secs(120)); // 命令行覆盖环境变量。 let options = parse_with_env( &["bat", "--output", "/cli/out"], &[("BAT_OUTPUT", "/srv/bat")], ) .unwrap(); assert_eq!(options.config.output_root, PathBuf::from("/cli/out")); // 空值视为未设置(模板中保留 `BAT_XXX=` 空行无副作用)。 let options = parse_with_env(&["bat"], &[("BAT_OUTPUT", "")]).unwrap(); assert_eq!( options.config.output_root, CliOptions::default().config.output_root ); } #[test] fn env_watch_daemon_only_affect_bare_run() { let options = parse_with_env(&["bat"], &[("BAT_WATCH", "1")]).unwrap(); assert!(options.watch); // 同时设置时 daemon 优先(daemon 自带 watch)。 let options = parse_with_env(&["bat"], &[("BAT_WATCH", "1"), ("BAT_DAEMON", "1")]).unwrap(); assert!(options.daemon); assert!(!options.watch); // 子命令不受影响:verify 内部置 dry-run,若误吃 env watch 会直接解析失败。 let options = parse_with_env(&["bat", "verify"], &[("BAT_WATCH", "1")]).unwrap(); assert!(!options.watch); assert!(!options.daemon); // 命令行显式 --dry-run 时 env watch 让位(命令行意图优先)。 let options = parse_with_env(&["bat", "--dry-run"], &[("BAT_WATCH", "1")]).unwrap(); assert!(!options.watch); // 命令行显式选择前台 watch 时 env daemon 让位。 let options = parse_with_env(&["bat", "--watch"], &[("BAT_DAEMON", "1")]).unwrap(); assert!(options.watch); assert!(!options.daemon); } #[test] fn env_values_do_not_break_status_and_reload_guard() { // .env 提供的代理/工具/输出目录不算"显式同步参数",status 应照常可用。 let options = parse_with_env( &["bat", "status"], &[ ("BAT_PROXY", "http://127.0.0.1:7897"), ("BAT_CURL", "/usr/bin/curl"), ("BAT_OUTPUT", "/srv/bat"), ], ) .unwrap(); assert_eq!(options.command, CliCommand::Status); assert!(!tools_are_non_default( &options.config, &options.env_baseline_config )); // 命令行再改工具才算偏离基线。 let options = parse_with_env( &["bat", "--curl", "/opt/curl"], &[("BAT_PROXY", "http://127.0.0.1:7897")], ) .unwrap(); assert!(tools_are_non_default( &options.config, &options.env_baseline_config )); } #[test] fn env_invalid_values_error() { assert!(parse_with_env(&["bat"], &[("BAT_WATCH", "maybe")]).is_err()); assert!(parse_with_env(&["bat"], &[("BAT_INTERVAL_SECONDS", "abc")]).is_err()); assert!(parse_with_env(&["bat"], &[("BAT_PROXY", "ftp://x")]).is_err()); } #[test] fn parse_env_line_handles_quotes_and_rejects_bad_keys() { assert_eq!( parse_env_line("KEY=value"), Some(("KEY".to_string(), "value".to_string())) ); assert_eq!( parse_env_line("KEY=\"quoted value\""), Some(("KEY".to_string(), "quoted value".to_string())) ); assert_eq!( parse_env_line("KEY='single'"), Some(("KEY".to_string(), "single".to_string())) ); assert_eq!( parse_env_line("BAT_OUTPUT = ./x"), Some(("BAT_OUTPUT".to_string(), "./x".to_string())) ); assert_eq!(parse_env_line("no_equals_sign"), None); assert_eq!(parse_env_line("1BAD=x"), None); assert_eq!(parse_env_line("BAD KEY=x"), None); } #[test] fn env_template_is_parseable_and_bootstrap_ready() { // 模板每个非注释行必须可解析;无参启动所需的最小配置默认启用。 let mut keys = Vec::new(); for line in ENV_TEMPLATE.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let (key, _) = parse_env_line(line).unwrap_or_else(|| panic!("模板行必须可解析:{line}")); keys.push(key); } assert!(keys.contains(&"BAT_OUTPUT".to_string())); assert!(keys.contains(&"BAT_AUTO_DISCOVER".to_string())); } #[test] fn task_persistence_round_trip_and_interrupt_marking() { let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path(); let (registry, summary) = TaskRegistry::with_persistence(state_dir); assert!(summary.contains("空任务历史"), "{summary}"); let finished_id = registry.create(TaskKind::Sync); registry.update(&finished_id, |record| { record.status = "running"; record.started_at = Some(record.created_at); }); registry.append_log(&finished_id, "进度 1".to_string()); registry.update(&finished_id, |record| { record.status = "succeeded"; record.finished_at = Some(record.created_at + 1); record.result = Some(serde_json::json!({ "ok": true })); }); let running_id = registry.create(TaskKind::Verify); registry.update(&running_id, |record| record.status = "running"); let failed_id = registry.create(TaskKind::Refresh); registry.update(&failed_id, |record| { record.status = "failed"; record.error = Some(ApiError::new( ErrorCode::HTTP_NOT_FOUND, "task.executor", "404", )); }); drop(registry); // 重启:恢复历史;running 任务标记中断;错误码经持久化往返保留。 let (registry, summary) = TaskRegistry::with_persistence(state_dir); assert!(summary.contains("恢复任务历史 3 条"), "{summary}"); assert!(summary.contains("标记中断 1 条"), "{summary}"); let finished = registry.get(&finished_id).unwrap(); assert_eq!(finished.status, "succeeded"); assert_eq!(finished.result, Some(serde_json::json!({ "ok": true }))); assert_eq!( registry.logs(&finished_id).unwrap(), vec!["进度 1".to_string()] ); let interrupted = registry.get(&running_id).unwrap(); assert_eq!(interrupted.status, "failed"); assert_eq!( interrupted.error.as_ref().unwrap().code(), ErrorCode::TASK_INTERRUPTED ); assert!(interrupted.finished_at.is_some()); let failed = registry.get(&failed_id).unwrap(); assert_eq!( failed.error.as_ref().unwrap().code(), ErrorCode::HTTP_NOT_FOUND ); // seq 持久化:重启后(本测试内 pid 相同)新任务不与历史撞 ID。 let new_id = registry.create(TaskKind::Sync); assert!( [&finished_id, &running_id, &failed_id] .iter() .all(|id| **id != new_id), "新任务 ID {new_id} 与历史撞号" ); assert_eq!(registry.list().len(), 4); } #[test] fn task_persistence_recovers_from_corrupt_file() { let temp = tempfile::TempDir::new().unwrap(); fs::write(temp.path().join(TASKS_FILE_NAME), b"not-json").unwrap(); let (registry, summary) = TaskRegistry::with_persistence(temp.path()); assert!(summary.contains("任务历史不可用"), "{summary}"); // 损坏文件改名留证,不静默覆盖。 assert!(temp.path().join("bat-tasks.json.corrupt").exists()); assert!(registry.list().is_empty()); // 恢复后可正常写入新历史。 registry.create(TaskKind::Sync); let bytes = fs::read(temp.path().join(TASKS_FILE_NAME)).unwrap(); let file: PersistedTaskFile = serde_json::from_slice(&bytes).unwrap(); assert_eq!(file.version, TASKS_FILE_VERSION); assert_eq!(file.tasks.len(), 1); assert_eq!(file.tasks[0].status, "queued"); } #[test] fn task_persistence_skips_unknown_kinds() { let temp = tempfile::TempDir::new().unwrap(); let file = serde_json::json!({ "version": TASKS_FILE_VERSION, "seq": 9, "tasks": [ { "id": "task-1-1", "kind": "patch.apply", "status": "succeeded", "created_at": 1, "updated_at": 2 }, { "id": "task-1-2", "kind": "resource.sync", "status": "succeeded", "created_at": 3, "updated_at": 4 } ] }); fs::write( temp.path().join(TASKS_FILE_NAME), serde_json::to_vec(&file).unwrap(), ) .unwrap(); let (registry, summary) = TaskRegistry::with_persistence(temp.path()); assert!(summary.contains("跳过无法识别 1 条"), "{summary}"); assert!(registry.get("task-1-1").is_none()); assert_eq!(registry.get("task-1-2").unwrap().status, "succeeded"); } #[test] fn parses_download_concurrency_flag_and_env() { // 默认值(与 OfficialUpdateConfig 默认一致)。 assert_eq!( parse(&["bat"]).unwrap().config.download_concurrency, OfficialUpdateConfig::default().download_concurrency ); // CLI 显式设置 + 上限钳制。 assert_eq!( parse(&["bat", "--download-concurrency", "16"]) .unwrap() .config .download_concurrency, 16 ); assert_eq!( parse(&["bat", "--download-concurrency", "9999"]) .unwrap() .config .download_concurrency, 256 ); // 0 与非数字报错。 assert!(parse(&["bat", "--download-concurrency", "0"]).is_err()); assert!(parse(&["bat", "--download-concurrency", "abc"]).is_err()); // 环境变量(含 .env)设置默认值;CLI 覆盖之。 assert_eq!( parse_with_env(&["bat"], &[("BAT_DOWNLOAD_CONCURRENCY", "12")]) .unwrap() .config .download_concurrency, 12 ); assert_eq!( parse_with_env( &["bat", "--download-concurrency", "3"], &[("BAT_DOWNLOAD_CONCURRENCY", "12")] ) .unwrap() .config .download_concurrency, 3 ); } #[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)); } fn rpc_request(method: &str, params: Option) -> JsonRpcRequest { JsonRpcRequest { jsonrpc: Some("2.0".to_string()), id: Some(serde_json::json!(1)), method: method.to_string(), params, } } #[test] fn canonical_rpc_method_resolves_aliases() { assert_eq!(canonical_rpc_method("bat.status"), RPC_METHOD_STATUS); assert_eq!(canonical_rpc_method("bat.refresh"), RPC_METHOD_REFRESH); assert_eq!(canonical_rpc_method("daemon.status"), RPC_METHOD_STATUS); assert_eq!(canonical_rpc_method("resource.state"), "resource.state"); assert_eq!(canonical_rpc_method("unknown.method"), "unknown.method"); } #[test] fn is_pending_rpc_method_covers_planned_namespaces() { assert!(is_pending_rpc_method("patch.apply")); assert!(is_pending_rpc_method("unityfs.inspect")); assert!(is_pending_rpc_method("task.create")); assert!(is_pending_rpc_method("resource.repair")); // sync/verify、task.cancel/logs、catalog.* 与 resource.manifest 已实现, // 不再是 pending。 assert!(!is_pending_rpc_method("resource.sync")); assert!(!is_pending_rpc_method("resource.verify")); assert!(!is_pending_rpc_method("resource.manifest")); assert!(!is_pending_rpc_method("catalog.status")); assert!(!is_pending_rpc_method("catalog.refresh")); assert!(!is_pending_rpc_method("task.cancel")); assert!(!is_pending_rpc_method("task.logs")); assert!(!is_pending_rpc_method("daemon.status")); } #[test] fn task_registry_cancel_and_logs() { let registry = TaskRegistry::new(); let id = registry.create(TaskKind::Sync); // 取消未结束任务:置标志。 assert!(matches!( registry.request_cancel(&id), CancelOutcome::Requested )); assert!(registry.cancel_flag(&id).unwrap().load(Ordering::Relaxed)); // 追加日志(超上限时丢弃最旧)。 for i in 0..(MAX_TASK_LOG_LINES + 5) { registry.append_log(&id, format!("line {i}")); } let logs = registry.logs(&id).unwrap(); assert_eq!(logs.len(), MAX_TASK_LOG_LINES); assert_eq!( logs.last().unwrap(), &format!("line {}", MAX_TASK_LOG_LINES + 4) ); // 已结束任务:AlreadyFinished;不存在:NotFound。 registry.update(&id, |record| record.status = "succeeded"); assert!(matches!( registry.request_cancel(&id), CancelOutcome::AlreadyFinished )); assert!(matches!( registry.request_cancel("nope"), CancelOutcome::NotFound )); } fn test_task_context() -> DaemonTaskContext { let (queue, _rx) = mpsc::channel::(); DaemonTaskContext { registry: TaskRegistry::new(), queue, base_config: OfficialUpdateConfig::default(), } } #[test] fn dispatch_unknown_method_returns_unknown_error_envelope() { let temp = tempfile::TempDir::new().unwrap(); let control = new_daemon_control(); let envelope = dispatch_rpc_method( &rpc_request("nope.nope", None), temp.path(), &control, &test_task_context(), "req-test-1".to_string(), ); let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], false); assert_eq!(value["error"]["code"], "BAT-ERR-700001"); assert_eq!(value["request_id"], "req-test-1"); } #[test] fn dispatch_pending_method_returns_not_implemented_envelope() { let temp = tempfile::TempDir::new().unwrap(); let control = new_daemon_control(); let envelope = dispatch_rpc_method( &rpc_request("patch.apply", None), temp.path(), &control, &test_task_context(), "req-test-2".to_string(), ); let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], false); assert_eq!(value["error"]["code"], "BAT-ERR-700003"); } #[test] fn dispatch_task_list_returns_empty_ok_envelope() { let temp = tempfile::TempDir::new().unwrap(); let control = new_daemon_control(); let envelope = dispatch_rpc_method( &rpc_request("task.list", None), temp.path(), &control, &test_task_context(), "req-test-3".to_string(), ); let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], true); assert_eq!(value["status"], "ok"); assert_eq!(value["data"]["tasks"], serde_json::json!([])); } #[test] fn dispatch_task_status_reports_task_not_found() { let temp = tempfile::TempDir::new().unwrap(); let control = new_daemon_control(); let envelope = dispatch_rpc_method( &rpc_request("task.status", Some(serde_json::json!({ "task_id": "abc" }))), temp.path(), &control, &test_task_context(), "req-test-4".to_string(), ); let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], false); assert_eq!(value["error"]["code"], "BAT-ERR-700004"); } #[test] fn dispatch_resource_sync_enqueues_task() { let temp = tempfile::TempDir::new().unwrap(); let control = new_daemon_control(); // 保留 rx 让 send 成功(不启动 worker,任务停留在 queued)。 let (queue, rx) = mpsc::channel::(); let context = DaemonTaskContext { registry: TaskRegistry::new(), queue, base_config: OfficialUpdateConfig::default(), }; let envelope = dispatch_rpc_method( &rpc_request("resource.sync", Some(serde_json::json!({ "force": true }))), temp.path(), &control, &context, "req-test-5".to_string(), ); let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], true); assert_eq!(value["status"], "accepted"); let task_id = value["data"]["task_id"].as_str().unwrap().to_string(); assert_eq!(value["data"]["kind"], "resource.sync"); // 注册表里应有一条 queued 任务。 let record = context.registry.get(&task_id).unwrap(); assert_eq!(record.status, "queued"); assert_eq!(record.kind, "resource.sync"); // 作业已入队,force 已透传到派生配置。 let job = rx.try_recv().unwrap(); assert_eq!(job.id, task_id); assert!(job.config.force); assert!(!job.config.dry_run); // task.status 能查到这条任务。 let status_envelope = dispatch_rpc_method( &rpc_request( "task.status", Some(serde_json::json!({ "task_id": task_id })), ), temp.path(), &control, &context, "req-test-6".to_string(), ); let status_value = serde_json::to_value(&status_envelope).unwrap(); assert_eq!(status_value["ok"], true); assert_eq!(status_value["data"]["status"], "queued"); } #[test] fn task_registry_create_update_and_prune() { let registry = TaskRegistry::new(); let id = registry.create(TaskKind::Verify); assert_eq!(registry.get(&id).unwrap().status, "queued"); registry.update(&id, |record| record.status = "running"); assert_eq!(registry.get(&id).unwrap().status, "running"); assert_eq!(registry.list().len(), 1); // 超过上限时裁剪最旧的已结束任务;未结束的保留。 for _ in 0..MAX_RETAINED_TASKS { let finished = registry.create(TaskKind::Sync); registry.update(&finished, |record| record.status = "succeeded"); } assert!(registry.list().len() <= MAX_RETAINED_TASKS); // 运行中的任务不被裁剪。 assert_eq!(registry.get(&id).unwrap().status, "running"); } #[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(); // bat.* 是兼容别名,解析为规范的 daemon.* 方法名。 assert_eq!(request.method, "bat.refresh"); assert_eq!(canonical_rpc_method(&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()], } } /// 写入 catalog 只读查询所需的完整 fixture:daemon 状态文件、版本目录内的 /// snapshot、版本状态文件。返回当前版本目录路径。 fn write_catalog_fixture( state_dir: &Path, output_root: &Path, current_bundle: &str, previous_bundle: Option<&str>, ) -> PathBuf { 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 make_version = |id: &str, bundle: &str| -> OfficialVersionRecord { let version_dir = output_root.join("versions").join(id); fs::create_dir_all(&version_dir).unwrap(); let snapshot_path = version_dir.join("official-sync-snapshot.json"); let snapshot = OfficialUpdateSnapshot { snapshot_version: bat_infrastructure::official_update::OFFICIAL_UPDATE_SNAPSHOT_VERSION, connection_group_name: "Prod-Audit".to_string(), app_version: "1.70.0".to_string(), bundle_version: Some(bundle.to_string()), addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture" .to_string(), endpoints: Vec::new(), endpoint_markers: Vec::new(), launcher_metadata: None, game_main_config_bootstrap: None, }; bat_infrastructure::write_snapshot(&snapshot_path, &snapshot).unwrap(); OfficialVersionRecord { id: id.to_string(), app_version: "1.70.0".to_string(), bundle_version: Some(bundle.to_string()), addressables_root: snapshot.addressables_root.clone(), resource_root: version_dir.clone(), snapshot_path, staging_path: None, version_path: Some(version_dir.clone()), started_unix_seconds: None, completed_unix_seconds: Some(unix_seconds_now()), } }; let current = make_version("v-current", current_bundle); let current_dir = current.resource_root.clone(); let state = OfficialVersionState { current_completed_version: Some(current), previous_available_version: previous_bundle .map(|bundle| make_version("v-previous", bundle)), ..OfficialVersionState::default() }; bat_infrastructure::write_version_state( &output_root.join("official-version-state.json"), &state, ) .unwrap(); current_dir } #[test] fn dispatch_catalog_status_unavailable_without_state() { let temp = tempfile::TempDir::new().unwrap(); let envelope = dispatch_rpc_method( &rpc_request("catalog.status", None), temp.path(), &new_daemon_control(), &test_task_context(), "req-cat-0".to_string(), ); let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], true); assert_eq!(value["data"]["available"], false); } #[test] fn dispatch_catalog_status_reads_current_snapshot() { let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path().join("state"); let output_root = temp.path().join("output"); write_catalog_fixture(&state_dir, &output_root, "bundle-b2", Some("bundle-b1")); let envelope = dispatch_rpc_method( &rpc_request("catalog.status", None), &state_dir, &new_daemon_control(), &test_task_context(), "req-cat-1".to_string(), ); let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], true); assert_eq!(value["data"]["available"], true); assert_eq!(value["data"]["bundle_version"], "bundle-b2"); assert_eq!(value["data"]["version"]["id"], "v-current"); assert_eq!(value["data"]["endpoint_count"], 0); assert_eq!(value["data"]["connection_group_name"], "Prod-Audit"); } #[test] fn dispatch_catalog_versions_lists_history() { let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path().join("state"); let output_root = temp.path().join("output"); write_catalog_fixture(&state_dir, &output_root, "bundle-b2", Some("bundle-b1")); let envelope = dispatch_rpc_method( &rpc_request("catalog.versions", None), &state_dir, &new_daemon_control(), &test_task_context(), "req-cat-2".to_string(), ); let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], true); assert_eq!(value["data"]["available"], true); assert_eq!(value["data"]["current"]["id"], "v-current"); assert_eq!(value["data"]["previous"]["id"], "v-previous"); assert_eq!(value["data"]["in_progress"], serde_json::Value::Null); assert!(value["data"]["failed"].as_array().unwrap().is_empty()); } #[test] fn dispatch_catalog_diff_reports_bundle_change() { let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path().join("state"); let output_root = temp.path().join("output"); write_catalog_fixture(&state_dir, &output_root, "bundle-b2", Some("bundle-b1")); let envelope = dispatch_rpc_method( &rpc_request("catalog.diff", None), &state_dir, &new_daemon_control(), &test_task_context(), "req-cat-3".to_string(), ); let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], true); assert_eq!(value["data"]["available"], true); assert_eq!(value["data"]["current_version_id"], "v-current"); assert_eq!(value["data"]["previous_version_id"], "v-previous"); assert_eq!(value["data"]["previous_snapshot_missing"], false); assert_eq!(value["data"]["base_delta"]["is_initial"], false); assert_eq!(value["data"]["base_delta"]["bundle_version_changed"], true); assert_eq!( value["data"]["base_delta"]["addressables_root_changed"], false ); } #[test] fn dispatch_catalog_diff_initial_without_previous() { let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path().join("state"); let output_root = temp.path().join("output"); write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None); let envelope = dispatch_rpc_method( &rpc_request("catalog.diff", None), &state_dir, &new_daemon_control(), &test_task_context(), "req-cat-4".to_string(), ); let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], true); assert_eq!(value["data"]["available"], true); assert_eq!( value["data"]["previous_version_id"], serde_json::Value::Null ); assert_eq!(value["data"]["base_delta"]["is_initial"], true); } #[test] fn dispatch_catalog_refresh_enqueues_task() { let temp = tempfile::TempDir::new().unwrap(); // 保留 rx 让 send 成功(不启动 worker,任务停留在 queued)。 let (queue, rx) = mpsc::channel::(); let tasks = DaemonTaskContext { registry: TaskRegistry::new(), queue, base_config: OfficialUpdateConfig::default(), }; let envelope = dispatch_rpc_method( &rpc_request("catalog.refresh", None), temp.path(), &new_daemon_control(), &tasks, "req-cat-5".to_string(), ); let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], true); assert_eq!(value["status"], "accepted"); assert_eq!(value["data"]["kind"], "catalog.refresh"); let task_id = value["data"]["task_id"].as_str().unwrap(); let record = tasks.registry.get(task_id).unwrap(); assert_eq!(record.kind, "catalog.refresh"); assert_eq!(record.status, "queued"); // 作业配置是 dry-run 计划检查,不下载不审计。 let job = rx.try_recv().unwrap(); assert_eq!(job.id, task_id); assert!(job.config.dry_run); assert!(job.config.plan); assert!(!job.config.audit_local); } #[test] fn catalog_refresh_config_is_dry_run_plan_only() { let base = OfficialUpdateConfig::default(); let config = TaskKind::Refresh.build_config(&base, false); assert!(config.dry_run); assert!(config.plan); assert!(!config.audit_local); assert!(!config.repair); assert!(!config.force); let forced = TaskKind::Refresh.build_config(&base, true); assert!(forced.force); } #[test] fn dispatch_resource_manifest_paginates() { let temp = tempfile::TempDir::new().unwrap(); let state_dir = temp.path().join("state"); let output_root = temp.path().join("output"); let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None); let manifest = serde_json::json!({ "version": 1, "entries": { "https://prod-clientpatch.bluearchiveyostar.com/a": { "url": "https://prod-clientpatch.bluearchiveyostar.com/a", "destination": "a", "bytes": 1, "blake3": "aa", }, "https://prod-clientpatch.bluearchiveyostar.com/b": { "url": "https://prod-clientpatch.bluearchiveyostar.com/b", "destination": "b", "bytes": 2, "blake3": "bb", }, "https://prod-clientpatch.bluearchiveyostar.com/c": { "url": "https://prod-clientpatch.bluearchiveyostar.com/c", "destination": "c", "bytes": 3, "blake3": "cc", }, }, }); fs::write( current_dir.join("official-download-manifest.json"), serde_json::to_vec(&manifest).unwrap(), ) .unwrap(); let envelope = dispatch_rpc_method( &rpc_request( "resource.manifest", Some(serde_json::json!({ "offset": 1, "limit": 2 })), ), &state_dir, &new_daemon_control(), &test_task_context(), "req-man-1".to_string(), ); let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], true); assert_eq!(value["data"]["available"], true); assert_eq!(value["data"]["total_entries"], 3); assert_eq!(value["data"]["offset"], 1); let entries = value["data"]["entries"].as_array().unwrap(); assert_eq!(entries.len(), 2); assert_eq!(entries[0]["destination"], "b"); assert_eq!(entries[1]["destination"], "c"); // 非法 limit → 参数错误。 let envelope = dispatch_rpc_method( &rpc_request("resource.manifest", Some(serde_json::json!({ "limit": 0 }))), &state_dir, &new_daemon_control(), &test_task_context(), "req-man-2".to_string(), ); let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], false); assert_eq!(value["error"]["code"], "BAT-ERR-700002"); } #[test] fn dispatch_resource_repair_reports_not_implemented() { let temp = tempfile::TempDir::new().unwrap(); let envelope = dispatch_rpc_method( &rpc_request("resource.repair", None), temp.path(), &new_daemon_control(), &test_task_context(), "req-rep-1".to_string(), ); let value = serde_json::to_value(&envelope).unwrap(); assert_eq!(value["ok"], false); assert_eq!(value["error"]["code"], "BAT-ERR-700003"); } }