mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 01:15:14 +08:00
下载主循环由串行改为「并发下载 + 串行簿记」三段式: - 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>
7679 lines
278 KiB
Rust
7679 lines
278 KiB
Rust
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(¤t_snapshot, previous_snapshot.as_ref());
|
||
let changed_urls = changed_endpoint_urls(&base_delta);
|
||
Ok(serde_json::json!({
|
||
"available": true,
|
||
"current_version_id": current_record.id,
|
||
"previous_version_id": previous_record.map(|record| record.id.clone()),
|
||
"previous_snapshot_missing": previous_record.is_some() && previous_snapshot.is_none(),
|
||
"base_delta": base_delta,
|
||
"extended_delta": extended_delta,
|
||
"changed_endpoint_urls": changed_urls,
|
||
}))
|
||
}
|
||
|
||
/// `resource.manifest`:当前版本下载 manifest 的分页查询。
|
||
fn build_resource_manifest_report(
|
||
state_dir: &Path,
|
||
offset: usize,
|
||
limit: usize,
|
||
) -> anyhow::Result<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(¤t_path) {
|
||
Ok(metadata) => metadata,
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||
return Ok(output_root.to_path_buf());
|
||
}
|
||
Err(error) => return Err(error.into()),
|
||
};
|
||
if !metadata.file_type().is_symlink() {
|
||
return Err(anyhow::anyhow!(
|
||
"current 已存在但不是 symlink:{}",
|
||
current_path.display()
|
||
));
|
||
}
|
||
let target = fs::read_link(¤t_path)?;
|
||
let target = if target.is_absolute() {
|
||
target
|
||
} else {
|
||
output_root.join(target)
|
||
};
|
||
lexical_absolute(&target).map_err(anyhow::Error::msg)
|
||
}
|
||
|
||
fn daemon_child_args(options: &CliOptions) -> Vec<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 优先于 watch(daemon 自带 watch)。
|
||
if matches!(options.command, CliCommand::Run) && !mode_flag_from_cli && !options.config.dry_run
|
||
{
|
||
if env_modes.daemon {
|
||
options.daemon = true;
|
||
} else if env_modes.watch {
|
||
options.watch = true;
|
||
}
|
||
}
|
||
|
||
match options.command {
|
||
CliCommand::Status | CliCommand::Stop | CliCommand::Logs => {
|
||
if options.sync_option_explicit
|
||
|| options.output_explicit
|
||
|| options.proxy_option_explicit
|
||
|| tools_are_non_default(&options.config, &options.env_baseline_config)
|
||
{
|
||
return Err(anyhow::anyhow!(
|
||
"status/stop/logs 只读取 --state-dir;资源同步参数和输出目录无关"
|
||
));
|
||
}
|
||
options.progress = false;
|
||
options.banner = false;
|
||
}
|
||
CliCommand::Doctor | CliCommand::CleanStable => {
|
||
if options.sync_option_explicit {
|
||
return Err(anyhow::anyhow!(
|
||
"doctor/clean-stable 只接受 --output、--state-dir、--curl、--proxy 和 --unzip 等诊断参数"
|
||
));
|
||
}
|
||
options.progress = false;
|
||
options.banner = false;
|
||
}
|
||
CliCommand::Refresh | CliCommand::Verify | CliCommand::Repair => {
|
||
if !options.config.auto_discover
|
||
&& options.config.server_info_source.is_none()
|
||
&& options.config.connection_group.is_none()
|
||
&& options.config.app_version.is_none()
|
||
{
|
||
options.config.auto_discover = true;
|
||
}
|
||
if matches!(options.command, CliCommand::Verify) {
|
||
options.config.dry_run = true;
|
||
options.config.plan = true;
|
||
options.config.audit_local = true;
|
||
options.config.repair = false;
|
||
}
|
||
}
|
||
CliCommand::Restart | CliCommand::Reload => {
|
||
if (options.sync_option_explicit
|
||
|| options.output_explicit
|
||
|| options.proxy_option_explicit
|
||
|| tools_are_non_default(&options.config, &options.env_baseline_config))
|
||
&& !options.config.auto_discover
|
||
&& options.config.server_info_source.is_none()
|
||
&& options.config.connection_group.is_none()
|
||
&& options.config.app_version.is_none()
|
||
{
|
||
options.config.auto_discover = true;
|
||
}
|
||
}
|
||
CliCommand::Run => {}
|
||
}
|
||
if options.daemon && options.watch {
|
||
return Err(anyhow::anyhow!(
|
||
"--daemon 会自动启动 watch 模式,不要同时传 --watch"
|
||
));
|
||
}
|
||
if (options.watch || options.daemon || options.daemon_child) && options.config.dry_run {
|
||
return Err(anyhow::anyhow!(
|
||
"watch/daemon 模式不能和 --dry-run 同时使用"
|
||
));
|
||
}
|
||
if options.interval.is_zero() {
|
||
return Err(anyhow::anyhow!("watch 检查间隔必须大于 0"));
|
||
}
|
||
if options.error_retry_interval.is_zero() {
|
||
return Err(anyhow::anyhow!("watch 失败重试间隔必须大于 0"));
|
||
}
|
||
if (options.watch || options.daemon || options.daemon_child)
|
||
&& !options.quiet_up_to_date_explicit
|
||
{
|
||
options.quiet_up_to_date = true;
|
||
}
|
||
if matches!(options.command, CliCommand::Verify) {
|
||
if options.config.force {
|
||
return Err(anyhow::anyhow!(
|
||
"verify 只验证本地资源,不能和 --force 同时使用"
|
||
));
|
||
}
|
||
if !options.config.repair && !options.config.audit_local {
|
||
return Err(anyhow::anyhow!(
|
||
"verify 必须启用本地审计;不要同时传 --no-repair --no-audit-local"
|
||
));
|
||
}
|
||
}
|
||
if matches!(options.command, CliCommand::Restart | CliCommand::Reload)
|
||
&& (options.config.force || options.config.dry_run)
|
||
{
|
||
return Err(anyhow::anyhow!(
|
||
"restart/reload 不能和 --force 或 --dry-run 同时使用"
|
||
));
|
||
}
|
||
if matches!(options.command, CliCommand::Logs) && options.tail_lines == 0 {
|
||
return Err(anyhow::anyhow!("logs 的 --tail 必须大于 0"));
|
||
}
|
||
|
||
Ok(options)
|
||
}
|
||
|
||
fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()> {
|
||
if command == CliCommand::Run {
|
||
Ok(())
|
||
} else {
|
||
Err(anyhow::anyhow!("不支持同时指定多个命令;意外命令:{next}"))
|
||
}
|
||
}
|
||
|
||
/// 判断 curl/代理/unzip 是否偏离基线。基线是环境变量(含 .env)应用后的
|
||
/// 配置快照,因此只有命令行显式传入才算"非默认"。
|
||
fn tools_are_non_default(config: &OfficialUpdateConfig, baseline: &OfficialUpdateConfig) -> bool {
|
||
config.curl_command != baseline.curl_command
|
||
|| config.curl_proxy != baseline.curl_proxy
|
||
|| config.unzip_command != baseline.unzip_command
|
||
}
|
||
|
||
fn next_option_value(
|
||
args: &mut impl Iterator<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");
|
||
}
|
||
}
|