Files
BlueArchiveToolkit/infrastructure/src/bin/bat_official_sync.rs
T
nyaKazuhaandClaude Fable 5 0ab3f3b953 feat(download): 多线程下载 + 重试指数退避(issue #17)
下载主循环由串行改为「并发下载 + 串行簿记」三段式:
- Phase A:无网络前置校验(官方性/目标路径/建目录)+ 判定跳过/需下载,
  非官方 URL 在任何下载前 fail-fast
- Phase B:need-download 项经 scoped 线程池并发下载(默认并发 4,
  可配 1..=256)。worker 只做只读 &self 的 pull_one(各 URL 独立
  .part/目标文件),经 mpsc 把结果送回主线程;manifest/quarantine
  簿记与进度回调全在主线程串行执行,无需加锁。首个失败或 should_cancel
  置 cancel 标志,其余 worker 在任务边界停止
- Phase C:按 plan 顺序串行收尾——seed .hash 校验(顺序相关、可
  fail-fast)+ 构建有序结果

fail-fast 与「不发布不完整资源」不变量保留;进度事件按 URL 配对但
顺序不再单调(并发下天然如此)。

curl 重试加指数退避(网络类 200ms→400ms→800ms…上限 5s;ETXTBSY 仍走
极短退避),并发下对官方 CDN 更礼貌;退避基值 cfg(test) 下为 0 不拖慢
单测。

并发度经 OfficialUpdateConfig.download_concurrency 贯通,CLI
--download-concurrency 与 BAT_DOWNLOAD_CONCURRENCY 可配,.env 模板
与 USERGUIDE/CURRENT_STATUS/CHANGELOG 同步。

验证:新增并发正确性测试(并发 8:每 URL 恰一次 started+finished、
全部落盘)、并发度钳制、退避时长计算、CLI/env 解析单测;progress
排序测试改为顺序无关不变量;workspace 全测试(20 套件) + fmt +
clippy --all-targets -D warnings 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 03:25:04 -07:00

7679 lines
278 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/<pid>/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<i32> {
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<u64>,
}
#[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::<TaskJob>();
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<PathBuf>,
started_unix_seconds: u64,
updated_unix_seconds: u64,
#[serde(default)]
last_success_unix_seconds: Option<u64>,
#[serde(default)]
next_check_unix_seconds: Option<u64>,
last_update_status: Option<String>,
last_error: Option<String>,
next_retry_seconds: Option<u64>,
#[serde(default)]
current_stage: Option<String>,
#[serde(default)]
current_message: Option<String>,
#[serde(default)]
download_progress: Option<DaemonDownloadProgress>,
pending_scheduled_force: bool,
next_forced_refresh_unix_seconds: Option<u64>,
command: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct DaemonDownloadProgress {
index: usize,
total: usize,
url: String,
status: Option<String>,
bytes: Option<u64>,
transferred_bytes: Option<u64>,
#[serde(default)]
failure_kind: Option<String>,
#[serde(default)]
failure_http_status: Option<u16>,
#[serde(default)]
failure_retryable: Option<bool>,
#[serde(default)]
failure_attempts: Option<usize>,
#[serde(default)]
quarantined: Option<bool>,
}
#[derive(Debug)]
struct DaemonStatusUpdate<'a> {
state: &'a str,
last_update_status: Option<String>,
last_error: Option<String>,
next_retry_seconds: Option<u64>,
last_success_unix_seconds: Option<u64>,
next_check_unix_seconds: Option<u64>,
pending_scheduled_force: bool,
next_forced_refresh_at: SystemTime,
}
type DaemonControl = Arc<(Mutex<DaemonControlState>, 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<String>,
id: Option<serde_json::Value>,
method: String,
params: Option<serde_json::Value>,
}
#[derive(Debug, Serialize)]
struct JsonRpcResponse {
jsonrpc: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<JsonRpcError>,
}
#[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<serde_json::Value>,
result: Option<serde_json::Value>,
error: Option<JsonRpcError>,
}
#[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<bool>,
}
// 规范方法名采用国际惯例的 `<namespace>.<action>`。`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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
created_at: u64,
updated_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
started_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
finished_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<ApiError>,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
/// 取消标志,worker 的 should_cancel 检查它;不参与序列化。
#[serde(skip)]
cancel: Arc<AtomicBool>,
/// 进度日志(有界),经 task.logs 返回;不参与 task.status 序列化。
#[serde(skip)]
log: Vec<String>,
}
impl TaskRecord {
fn is_finished(&self) -> bool {
matches!(self.status, "succeeded" | "failed" | "cancelled")
}
}
struct TaskStore {
tasks: HashMap<String, TaskRecord>,
order: Vec<String>,
seq: u64,
/// 任务历史持久化文件路径;`None` 表示纯内存(测试等非 daemon 场景)。
persist_path: Option<PathBuf>,
}
/// 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<PersistedTaskRecord>,
}
#[derive(Debug, Serialize, Deserialize)]
struct PersistedTaskRecord {
id: String,
kind: String,
status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
stage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
message: Option<String>,
created_at: u64,
updated_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
started_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
finished_at: Option<u64>,
/// `ApiError` 的序列化形态(code/kind/domain/location/message/retryable)。
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
log: Vec<String>,
}
/// 把持久化的任务类型映射回静态字符串;未识别(如未来版本新增)返回 `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<TaskRecord> {
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<Option<PersistedTaskFile>, 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<Mutex<TaskStore>>,
}
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<F: FnOnce(&mut TaskRecord)>(&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<TaskRecord> {
self.lock().tasks.get(id).cloned()
}
/// 返回任务的取消标志(与 worker 共享同一 Arc)。
fn cancel_flag(&self, id: &str) -> Option<Arc<AtomicBool>> {
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<Vec<String>> {
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<TaskRecord> {
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<AtomicBool>,
}
/// daemon 任务上下文:RPC handler 借它创建任务、入队和读取。
#[derive(Clone)]
struct DaemonTaskContext {
registry: TaskRegistry,
queue: mpsc::Sender<TaskJob>,
base_config: OfficialUpdateConfig,
}
/// 任务 worker:单线程 FIFO 消费任务队列,串行执行官方同步/校验。
///
/// 每个任务执行前获取进程内 `sync_lock`,与 watch 循环互斥(等待而非撞文件锁失败);
/// 进度写入任务记录;`should_cancel` 接 daemon 停止标志,停机时中止在途任务。
fn run_task_worker(
receiver: mpsc::Receiver<TaskJob>,
registry: TaskRegistry,
sync_lock: Arc<Mutex<()>>,
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::<bat_infrastructure::DownloadError>()
.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_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<ApiError>,
request_id: String,
}
/// 生成进程内唯一的 request_id`req-<pid>-<seq>`)。
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<Value>` 转成 envelope;成功为 ok,失败归为内部错误码。
fn rpc_envelope_from_result(
request_id: String,
location: &'static str,
result: anyhow::Result<serde_json::Value>,
) -> 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<Self> {
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<PidLockState> {
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<bool> {
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<String> {
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<u32> {
contents.trim().parse::<u32>().ok().filter(|pid| *pid > 0)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DaemonResourceConflict {
pid: Option<u32>,
daemon_root: Option<PathBuf>,
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<Option<DaemonResourceConflict>> {
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<PathBuf> {
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<DaemonWake> {
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<DaemonRpcServer> {
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<DaemonRpcServer> {
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::<JsonRpcRequest>(&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<bool>,
) -> 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<DaemonStatusFile>, Option<OfficialVersionState>)> {
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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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(&current_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<serde_json::Value> {
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<serde_json::Value>, result: serde_json::Value) -> JsonRpcResponse {
JsonRpcResponse {
jsonrpc: "2.0",
id,
result: Some(result),
error: None,
}
}
fn json_rpc_error(id: Option<serde_json::Value>, 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<bool> {
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<usize> {
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<serde_json::Value>,
) -> anyhow::Result<serde_json::Value> {
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<serde_json::Value>,
) -> anyhow::Result<serde_json::Value> {
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<bool> {
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<bool> {
path_exists_no_follow(socket_path)
}
fn path_exists_no_follow(path: &Path) -> anyhow::Result<bool> {
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<u32>,
resource_output_root: Option<PathBuf>,
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<PathBuf>,
structured_log_path: Option<PathBuf>,
rotated_structured_log_paths: Vec<PathBuf>,
started_unix_seconds: Option<u64>,
updated_unix_seconds: Option<u64>,
last_success_unix_seconds: Option<u64>,
next_check_unix_seconds: Option<u64>,
daemon_state: Option<String>,
last_update_status: Option<String>,
last_error: Option<String>,
next_retry_seconds: Option<u64>,
current_stage: Option<String>,
current_message: Option<String>,
download_progress: Option<DaemonDownloadProgress>,
version_state_path: Option<PathBuf>,
version_state: Option<OfficialVersionState>,
pending_scheduled_force: Option<bool>,
next_forced_refresh_unix_seconds: Option<u64>,
command: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
struct DaemonStopReport {
status: &'static str,
message: &'static str,
stopped: bool,
pid: Option<u32>,
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<DaemonStartReport> {
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<String>,
proxy_url: Option<String>,
message: &'static str,
) -> anyhow::Result<DaemonStartReport> {
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/<pid>/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<DaemonStopReport> {
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<DaemonStatusReport> {
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<String>) -> Vec<String> {
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<u32>,
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::<Vec<_>>();
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<T> {
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<T>(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<String, String> {
let progress = serde_json::from_value::<DaemonDownloadProgress>(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::<OfficialVersionState>(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<T>(label: &str, value: Option<T>)
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<String> {
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::<Vec<_>>()
.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<T> HumanReport for CommandReport<T>
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::<Vec<_>>();
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<u64>,
actual_bytes: Option<u64>,
expected_blake3: Option<String>,
actual_blake3: Option<String>,
zip_error: Option<String>,
}
#[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<usize>,
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<String>,
failures: Vec<VerificationItemReport>,
}
fn run_verify_command(options: &CliOptions) -> anyhow::Result<bool> {
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::<Vec<_>>();
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<LogsReport> {
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::<Vec<_>>();
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<DoctorCheck>,
}
fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
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<PathBuf>,
skipped_paths: Vec<PathBuf>,
}
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<PathBuf>) -> 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<Option<DaemonStatusFile>> {
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<Option<u32>> {
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::<u32>().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<PathBuf> {
(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<String> {
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<Option<String>> {
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<PathBuf> {
let current_path = output_root.join(OFFICIAL_CURRENT_LINK);
let metadata = match fs::symlink_metadata(&current_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(&current_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<String> {
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::<Vec<_>>()
.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/<pid>/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<u64> {
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<bool> {
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<RotatingStructuredLogger>,
}
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<str>) {
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_STATE_DIR>/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<String>,
) -> anyhow::Result<EnvModeOverrides> {
fn parse_env_bool(key: &str, value: &str) -> anyhow::Result<bool> {
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<Duration> {
let seconds = value
.parse::<u64>()
.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<CliOptions> {
parse_args_with_env(env::args(), |key| env::var(key).ok())
}
/// 测试入口:不读环境变量,解析结果只由参数决定。
#[cfg(test)]
fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result<CliOptions> {
parse_args_with_env(raw_args, |_| None)
}
fn parse_args_with_env(
raw_args: impl IntoIterator<Item = String>,
env_lookup: impl Fn(&str) -> Option<String>,
) -> anyhow::Result<CliOptions> {
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::<u64>()
.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::<u64>()
.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::<usize>()
.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 优先于 watchdaemon 自带 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<Item = String>,
flag: &str,
) -> anyhow::Result<String> {
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<CurlProxyConfig> {
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} <COMMAND> [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 <URL> Use an official server-info URL");
eprintln!(" --server-info-file <NAME> Use an official server-info file name");
eprintln!(" --server-info-path <PATH> Use a local server-info JSON file");
eprintln!(" --app-version <VERSION> Override app version");
eprintln!(" --connection-group <NAME> Override connection group");
eprintln!(" --launcher-version <VERSION> Launcher metadata API version (default: 1.7.2)");
eprintln!();
eprintln!("Sync:");
eprintln!(" --platforms <LIST> Platforms, e.g. Windows,Android");
eprintln!(
" --output <DIR> Resource publish root (default: ./bat-resources)"
);
eprintln!(" --snapshot <PATH> Override snapshot path (default: <output>/current/official-sync-snapshot.json)");
eprintln!(" --curl <PATH> curl executable (default: curl)");
eprintln!(" --proxy <URL|auto|none> curl proxy override (default: auto from env)");
eprintln!(" --no-proxy Force direct curl connections");
eprintln!(" --unzip <PATH> unzip executable (default: unzip)");
eprintln!(" --download-concurrency <N> 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 <DIR> Daemon state dir (default: /tmp/bat-pid)");
eprintln!(" --interval <DURATION> Normal check interval (default: 1h)");
eprintln!(" --error-retry <DURATION> 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 <N> 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/<id>, .staging/<id>)");
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<Duration> {
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::<u64>()
.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::<u64>()
.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<Vec<PatchPlatform>, 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<usize> {
let parsed = value
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("下载并发度无效:{error}"))?;
if parsed == 0 {
return Err(anyhow::anyhow!("下载并发度必须大于 0"));
}
Ok(parsed.min(256))
}
fn parse_platform(value: &str) -> Result<PatchPlatform, String> {
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<CliOptions> {
parse_args_from(values.iter().map(|value| value.to_string()))
}
fn parse_with_env(values: &[&str], env: &[(&str, &str)]) -> anyhow::Result<CliOptions> {
let map: HashMap<String, String> = 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<serde_json::Value>) -> 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::<TaskJob>();
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::<TaskJob>();
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://<redacted>@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], "<redacted>@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::<TaskJob>();
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");
}
}