mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 01:15:14 +08:00
- 代理故障分类:curl 因代理 407 认证失败(CONNECT 隧道失败)、无法解析/连接 代理等,原被归为普通可重试网络错误,永不成功也会耗尽 per-URL 重试且无诊断。 新增 CurlFailureKind::Proxy,从 curl stderr 识别代理故障,per-URL 层 fail-fast 不重试(daemon error-retry 循环仍整体重试以覆盖代理短暂抖动),并在失败摘要里 以 kind=proxy 明确标注。 - 前置校验:parse_proxy_config 新增 validate_proxy_url,拒绝拼错的代理 scheme (如 htp://),只接受 http/https/socks4/socks4a/socks5/socks5h 或无 scheme 的 host:port。 - doctor:proxy 检查不再恒 ok,对解析出的代理 URL(含 auto 从环境变量取得的) 做 scheme 校验,前置暴露配置错误。 新增单测:代理 407/解析失败归为 Proxy 且不重试、源站连接失败仍为 Connect 可重试、 拒绝不支持的 scheme、无 scheme host:port 接受。 对应 issue #18 维护清单 2-5。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5388 lines
191 KiB
Rust
5388 lines
191 KiB
Rust
use bat_adapters::official::yostar_jp::PatchPlatform;
|
||
use bat_infrastructure::{
|
||
gc_orphan_staging, lexical_absolute, open_append_file, read_file_no_symlink,
|
||
read_version_state, redact_proxy_url, resolve_curl_proxy, validate_output_root,
|
||
validate_runtime_state_dir, write_file_atomic, CurlProxyConfig, CurlProxyMode,
|
||
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
|
||
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus,
|
||
OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PRIVATE_FILE_MODE,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
use std::env;
|
||
use std::fs::{self, OpenOptions};
|
||
use std::io::{BufRead, BufReader, Write};
|
||
#[cfg(unix)]
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
#[cfg(unix)]
|
||
use std::os::unix::net::{UnixListener, UnixStream};
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::{Command, Stdio};
|
||
use std::sync::{Arc, Condvar, Mutex};
|
||
use std::thread;
|
||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||
|
||
const EXIT_ERROR: i32 = 1;
|
||
const EXIT_LOCKED: i32 = 75;
|
||
const DEFAULT_WATCH_INTERVAL_SECONDS: u64 = 60 * 60;
|
||
const DEFAULT_ERROR_RETRY_SECONDS: u64 = 60;
|
||
const DEFAULT_DAEMON_STATE_DIR: &str = "/tmp/bat-pid";
|
||
const DAEMON_PID_FILE: &str = "bat.pid";
|
||
const DAEMON_STATUS_FILE: &str = "bat-status.json";
|
||
const DAEMON_LOG_FILE: &str = "bat-daemon.log";
|
||
const DAEMON_STRUCTURED_LOG_FILE: &str = "bat-events.jsonl";
|
||
const DAEMON_SOCKET_FILE: &str = "bat.sock";
|
||
const DAEMON_CONTROL_LOCK_FILE: &str = "bat-control.lock";
|
||
/// 后台进程保存代理凭据的专用文件,仅供 restart/reload 复用,永不序列化到状态输出。
|
||
const DAEMON_PROXY_SECRET_FILE: &str = "bat-proxy.secret";
|
||
/// 代理凭据下传子进程使用的环境变量;避免凭据出现在子进程 argv(/proc/<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: localize_error_message(&error_message),
|
||
next_retry_seconds: None,
|
||
};
|
||
eprintln!(
|
||
"{}",
|
||
serde_json::to_string_pretty(&payload)
|
||
.unwrap_or_else(|_| "{\"status\":\"error\"}".to_string())
|
||
);
|
||
std::process::exit(exit_code);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn run() -> anyhow::Result<i32> {
|
||
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,
|
||
}
|
||
|
||
impl Default for CliOptions {
|
||
fn default() -> Self {
|
||
Self {
|
||
config: OfficialUpdateConfig::default(),
|
||
command: CliCommand::Run,
|
||
output_format: OutputFormat::Human,
|
||
watch: false,
|
||
daemon: false,
|
||
daemon_child: false,
|
||
state_dir: PathBuf::from(DEFAULT_DAEMON_STATE_DIR),
|
||
output_explicit: false,
|
||
sync_option_explicit: false,
|
||
proxy_option_explicit: false,
|
||
interval: Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS),
|
||
error_retry_interval: Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS),
|
||
quiet_up_to_date: false,
|
||
quiet_up_to_date_explicit: false,
|
||
progress: true,
|
||
banner: true,
|
||
tail_lines: 200,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum OutputFormat {
|
||
Human,
|
||
Json,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum CliCommand {
|
||
Run,
|
||
Status,
|
||
Stop,
|
||
Restart,
|
||
Reload,
|
||
Refresh,
|
||
Verify,
|
||
Repair,
|
||
Doctor,
|
||
Logs,
|
||
CleanStable,
|
||
}
|
||
|
||
fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
||
if options.config.dry_run {
|
||
return Err(anyhow::anyhow!("watch 常驻模式不能和 --dry-run 同时使用"));
|
||
}
|
||
if options.interval.is_zero() {
|
||
return Err(anyhow::anyhow!("watch 检查间隔必须大于 0"));
|
||
}
|
||
if options.error_retry_interval.is_zero() {
|
||
return Err(anyhow::anyhow!("watch 失败重试间隔必须大于 0"));
|
||
}
|
||
if options.daemon_child {
|
||
validate_runtime_state_dir(&options.state_dir).map_err(anyhow::Error::msg)?;
|
||
}
|
||
let service = OfficialUpdateService::new();
|
||
let mut logger = ProgressLogger::new(options.progress);
|
||
let daemon_state_dir = options.state_dir.clone();
|
||
if options.daemon_child {
|
||
logger.attach_structured_log(daemon_structured_log_path(&daemon_state_dir));
|
||
}
|
||
let daemon_control = if options.daemon_child {
|
||
Some(new_daemon_control())
|
||
} else {
|
||
None
|
||
};
|
||
let _rpc_server = if let Some(control) = daemon_control.as_ref() {
|
||
Some(start_daemon_rpc_server(
|
||
&daemon_state_dir,
|
||
Arc::clone(control),
|
||
)?)
|
||
} else {
|
||
None
|
||
};
|
||
let mut next_forced_refresh_at = next_forced_refresh_at_or_after(SystemTime::now());
|
||
let mut pending_scheduled_force = false;
|
||
let mut pending_rpc_force = false;
|
||
logger.log_text(
|
||
"watch",
|
||
format!(
|
||
"每日强制刷新时间:{DAILY_FORCED_REFRESH_LABEL};距离下一次强制刷新还有 {}",
|
||
format_duration(duration_until(next_forced_refresh_at, SystemTime::now()))
|
||
),
|
||
);
|
||
loop {
|
||
if daemon_control_stop_requested(daemon_control.as_ref()) {
|
||
logger.log_text("daemon", "收到停止请求,watch 循环准备退出");
|
||
break;
|
||
}
|
||
|
||
let now = SystemTime::now();
|
||
if now >= next_forced_refresh_at {
|
||
pending_scheduled_force = true;
|
||
logger.log_text(
|
||
"watch",
|
||
format!("已触发固定时间强制刷新:{DAILY_FORCED_REFRESH_LABEL}"),
|
||
);
|
||
next_forced_refresh_at = next_forced_refresh_after(now);
|
||
}
|
||
|
||
let mut iteration_config = options.config.clone();
|
||
if pending_scheduled_force {
|
||
iteration_config.force = true;
|
||
}
|
||
let rpc_force_this_round = pending_rpc_force;
|
||
if rpc_force_this_round {
|
||
iteration_config.force = true;
|
||
pending_rpc_force = false;
|
||
}
|
||
|
||
let mut sleep_for = options.interval;
|
||
logger.log_text(
|
||
"watch",
|
||
if pending_scheduled_force && rpc_force_this_round {
|
||
"开始执行 watch 轮次:本轮为固定时间和 RPC 触发的强制刷新"
|
||
} else if pending_scheduled_force {
|
||
"开始执行 watch 轮次:本轮为固定时间强制刷新"
|
||
} else if rpc_force_this_round {
|
||
"开始执行 watch 轮次:本轮为 RPC 触发的强制刷新"
|
||
} else {
|
||
"开始执行 watch 轮次"
|
||
},
|
||
);
|
||
record_daemon_status(
|
||
options.daemon_child,
|
||
&daemon_state_dir,
|
||
&mut logger,
|
||
DaemonStatusUpdate {
|
||
state: "running",
|
||
last_update_status: None,
|
||
last_error: None,
|
||
next_retry_seconds: None,
|
||
last_success_unix_seconds: None,
|
||
next_check_unix_seconds: None,
|
||
pending_scheduled_force,
|
||
next_forced_refresh_at,
|
||
},
|
||
);
|
||
match service.run_with_progress_and_cancellation(
|
||
&iteration_config,
|
||
|event| {
|
||
record_daemon_progress(
|
||
options.daemon_child,
|
||
&daemon_state_dir,
|
||
&mut logger,
|
||
&event,
|
||
);
|
||
logger.log(event);
|
||
},
|
||
|| daemon_control_stop_requested(daemon_control.as_ref()),
|
||
) {
|
||
Ok(report) => {
|
||
if pending_scheduled_force {
|
||
pending_scheduled_force = false;
|
||
}
|
||
if should_print_status(report.update_status, options.quiet_up_to_date) {
|
||
print_report(options.output_format, &report)?;
|
||
}
|
||
sleep_for =
|
||
sleep_for.min(duration_until(next_forced_refresh_at, SystemTime::now()));
|
||
record_daemon_status(
|
||
options.daemon_child,
|
||
&daemon_state_dir,
|
||
&mut logger,
|
||
DaemonStatusUpdate {
|
||
state: "sleeping",
|
||
last_update_status: Some(report.update_status.as_str().to_string()),
|
||
last_error: None,
|
||
next_retry_seconds: Some(sleep_for.as_secs()),
|
||
last_success_unix_seconds: Some(unix_seconds_now()),
|
||
next_check_unix_seconds: Some(unix_seconds_after(sleep_for)),
|
||
pending_scheduled_force,
|
||
next_forced_refresh_at,
|
||
},
|
||
);
|
||
logger.log_text(
|
||
"watch",
|
||
format!(
|
||
"本轮完成:状态={};下次检查将在 {} 后执行;距离下一次固定强制刷新还有 {}",
|
||
report.update_status.as_str(),
|
||
format_duration(sleep_for),
|
||
format_duration(duration_until(next_forced_refresh_at, SystemTime::now()))
|
||
),
|
||
);
|
||
}
|
||
Err(error) => {
|
||
sleep_for = options.error_retry_interval;
|
||
sleep_for =
|
||
sleep_for.min(duration_until(next_forced_refresh_at, SystemTime::now()));
|
||
record_daemon_status(
|
||
options.daemon_child,
|
||
&daemon_state_dir,
|
||
&mut logger,
|
||
DaemonStatusUpdate {
|
||
state: "error",
|
||
last_update_status: None,
|
||
last_error: Some(localize_error_message(&error.to_string())),
|
||
next_retry_seconds: Some(sleep_for.as_secs()),
|
||
last_success_unix_seconds: None,
|
||
next_check_unix_seconds: Some(unix_seconds_after(sleep_for)),
|
||
pending_scheduled_force,
|
||
next_forced_refresh_at,
|
||
},
|
||
);
|
||
logger.log_text(
|
||
"watch",
|
||
format!(
|
||
"本轮失败;将在 {} 后重试:{}",
|
||
format_duration(sleep_for),
|
||
localize_error_message(&error.to_string())
|
||
),
|
||
);
|
||
let payload = ErrorReport {
|
||
status: "error",
|
||
exit_code: EXIT_ERROR,
|
||
error: localize_error_message(&error.to_string()),
|
||
next_retry_seconds: Some(sleep_for.as_secs()),
|
||
};
|
||
eprintln!("{}", serde_json::to_string_pretty(&payload)?);
|
||
if daemon_control_stop_requested(daemon_control.as_ref()) {
|
||
logger.log_text("daemon", "停止请求已中断本轮同步,watch 循环准备退出");
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
match wait_for_daemon_wake(daemon_control.as_ref(), sleep_for) {
|
||
DaemonWake::Timeout => {}
|
||
DaemonWake::Stop => {
|
||
logger.log_text("daemon", "收到停止请求,watch 循环准备退出");
|
||
break;
|
||
}
|
||
DaemonWake::Refresh { force } => {
|
||
pending_rpc_force = force;
|
||
logger.log_text(
|
||
"daemon",
|
||
if force {
|
||
"收到 RPC refresh --force 请求,将尽快执行强制刷新"
|
||
} else {
|
||
"收到 RPC refresh 请求,将尽快执行刷新检查"
|
||
},
|
||
);
|
||
}
|
||
DaemonWake::Reload => {
|
||
pending_rpc_force = true;
|
||
logger.log_text(
|
||
"daemon",
|
||
"收到 RPC reload 请求,将尽快重新执行自动发现和强制刷新",
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
record_daemon_status(
|
||
options.daemon_child,
|
||
&daemon_state_dir,
|
||
&mut logger,
|
||
DaemonStatusUpdate {
|
||
state: "stopped",
|
||
last_update_status: None,
|
||
last_error: None,
|
||
next_retry_seconds: None,
|
||
last_success_unix_seconds: None,
|
||
next_check_unix_seconds: None,
|
||
pending_scheduled_force,
|
||
next_forced_refresh_at,
|
||
},
|
||
);
|
||
if options.daemon_child {
|
||
let _ = fs::remove_file(daemon_pid_path(&daemon_state_dir));
|
||
let _ = fs::remove_file(daemon_socket_path(&daemon_state_dir));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
struct DaemonStatusFile {
|
||
version: u32,
|
||
pid: u32,
|
||
state: String,
|
||
resource_output_root: PathBuf,
|
||
state_dir: PathBuf,
|
||
log_path: PathBuf,
|
||
#[serde(default)]
|
||
structured_log_path: Option<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>,
|
||
}
|
||
|
||
const RPC_METHOD_STATUS: &str = "bat.status";
|
||
const RPC_METHOD_STOP: &str = "bat.stop";
|
||
const RPC_METHOD_RELOAD: &str = "bat.reload";
|
||
const RPC_METHOD_REFRESH: &str = "bat.refresh";
|
||
const RPC_METHOD_LOGS: &str = "bat.logs";
|
||
|
||
fn record_daemon_status(
|
||
enabled: bool,
|
||
state_dir: &Path,
|
||
logger: &mut ProgressLogger,
|
||
update: DaemonStatusUpdate<'_>,
|
||
) {
|
||
if !enabled {
|
||
return;
|
||
}
|
||
|
||
if let Err(error) = update_daemon_status(state_dir, update) {
|
||
logger.log_text("daemon", format!("更新后台状态失败:{error}"));
|
||
}
|
||
}
|
||
|
||
fn record_daemon_progress(
|
||
enabled: bool,
|
||
state_dir: &Path,
|
||
logger: &mut ProgressLogger,
|
||
event: &OfficialUpdateProgress,
|
||
) {
|
||
if !enabled {
|
||
return;
|
||
}
|
||
|
||
if let Err(error) = update_daemon_progress(state_dir, event) {
|
||
logger.log_text("daemon", format!("更新后台进度失败:{error}"));
|
||
}
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct DaemonRpcServer {
|
||
socket_path: PathBuf,
|
||
}
|
||
|
||
impl Drop for DaemonRpcServer {
|
||
fn drop(&mut self) {
|
||
let _ = fs::remove_file(&self.socket_path);
|
||
}
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct DaemonControlLock {
|
||
path: PathBuf,
|
||
pid: u32,
|
||
}
|
||
|
||
impl DaemonControlLock {
|
||
fn acquire(state_dir: &Path) -> anyhow::Result<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,
|
||
) -> 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 _ = thread::Builder::new()
|
||
.name("bat-daemon-rpc-client".to_string())
|
||
.spawn(move || handle_daemon_rpc_client(stream, state_dir, control));
|
||
}
|
||
Err(error) => {
|
||
eprintln!("[daemon] RPC socket accept 失败:{error}");
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
})?;
|
||
|
||
Ok(DaemonRpcServer { socket_path })
|
||
}
|
||
|
||
#[cfg(not(unix))]
|
||
fn start_daemon_rpc_server(
|
||
_state_dir: &Path,
|
||
_control: DaemonControl,
|
||
) -> anyhow::Result<DaemonRpcServer> {
|
||
Err(anyhow::anyhow!("daemon RPC 目前只支持 Unix/Linux 平台"))
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn handle_daemon_rpc_client(mut stream: UnixStream, state_dir: PathBuf, control: DaemonControl) {
|
||
let Ok(reader_stream) = stream.try_clone() else {
|
||
return;
|
||
};
|
||
let mut reader = BufReader::new(reader_stream);
|
||
let mut line = String::new();
|
||
loop {
|
||
line.clear();
|
||
let bytes_read = match reader.read_line(&mut line) {
|
||
Ok(bytes_read) => bytes_read,
|
||
Err(error) => {
|
||
eprintln!("[daemon] RPC socket 读取失败:{error}");
|
||
return;
|
||
}
|
||
};
|
||
if bytes_read == 0 {
|
||
return;
|
||
}
|
||
if line.trim().is_empty() {
|
||
continue;
|
||
}
|
||
|
||
let mut notify_stop_after_response = false;
|
||
let response = match serde_json::from_str::<JsonRpcRequest>(&line) {
|
||
Ok(request) => {
|
||
notify_stop_after_response = request.method == RPC_METHOD_STOP;
|
||
handle_daemon_rpc_request(request, &state_dir, &control)
|
||
}
|
||
Err(error) => json_rpc_error(None, -32700, format!("JSON-RPC 请求解析失败:{error}")),
|
||
};
|
||
if let Err(error) = write_json_rpc_response(&mut stream, &response) {
|
||
if notify_stop_after_response {
|
||
daemon_control_notify_all(&control);
|
||
}
|
||
eprintln!("[daemon] RPC socket 写入失败:{error}");
|
||
return;
|
||
}
|
||
if notify_stop_after_response {
|
||
daemon_control_notify_all(&control);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn handle_daemon_rpc_request(
|
||
request: JsonRpcRequest,
|
||
state_dir: &Path,
|
||
control: &DaemonControl,
|
||
) -> JsonRpcResponse {
|
||
let id = request.id.clone();
|
||
let result: anyhow::Result<serde_json::Value> = match request.method.as_str() {
|
||
RPC_METHOD_STATUS => {
|
||
let report = build_daemon_status_report(state_dir);
|
||
report.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from))
|
||
}
|
||
RPC_METHOD_STOP => {
|
||
daemon_control_mark_stop_requested(control);
|
||
let _ = update_daemon_state_only(state_dir, "stopping");
|
||
serde_json::to_value(DaemonRpcAck {
|
||
command: "stop",
|
||
status: "accepted",
|
||
message: "后台停止请求已发送",
|
||
state_dir: state_dir.to_path_buf(),
|
||
socket_path: daemon_socket_path(state_dir),
|
||
force: None,
|
||
})
|
||
.map_err(anyhow::Error::from)
|
||
}
|
||
RPC_METHOD_RELOAD => {
|
||
daemon_control_request_reload(control);
|
||
serde_json::to_value(DaemonRpcAck {
|
||
command: "reload",
|
||
status: "accepted",
|
||
message: "后台重新加载请求已发送;空闲时会立即唤醒,忙碌时会在当前轮结束后重新执行自动发现和强制刷新",
|
||
state_dir: state_dir.to_path_buf(),
|
||
socket_path: daemon_socket_path(state_dir),
|
||
force: Some(true),
|
||
})
|
||
.map_err(anyhow::Error::from)
|
||
}
|
||
RPC_METHOD_REFRESH => {
|
||
let force = rpc_bool_param(request.params.as_ref(), "force").unwrap_or(false);
|
||
daemon_control_request_refresh(control, force);
|
||
serde_json::to_value(DaemonRpcAck {
|
||
command: "refresh",
|
||
status: "accepted",
|
||
message: if force {
|
||
"后台强制刷新请求已发送;空闲时会立即唤醒,忙碌时会在当前轮结束后执行"
|
||
} else {
|
||
"后台刷新检查请求已发送;空闲时会立即唤醒,忙碌时会在当前轮结束后执行"
|
||
},
|
||
state_dir: state_dir.to_path_buf(),
|
||
socket_path: daemon_socket_path(state_dir),
|
||
force: Some(force),
|
||
})
|
||
.map_err(anyhow::Error::from)
|
||
}
|
||
RPC_METHOD_LOGS => {
|
||
let tail = match rpc_tail_param(request.params.as_ref(), 200) {
|
||
Ok(tail) => tail,
|
||
Err(error) => return json_rpc_error(id, -32602, error.to_string()),
|
||
};
|
||
let report = build_logs_report(state_dir, tail);
|
||
report.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from))
|
||
}
|
||
_ => {
|
||
return json_rpc_error(id, -32601, format!("未知后台 RPC 方法:{}", request.method));
|
||
}
|
||
};
|
||
|
||
match result {
|
||
Ok(value) => json_rpc_result(id, value),
|
||
Err(error) => json_rpc_error(id, -32603, error.to_string()),
|
||
}
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn write_json_rpc_response(
|
||
stream: &mut UnixStream,
|
||
response: &JsonRpcResponse,
|
||
) -> anyhow::Result<()> {
|
||
serde_json::to_writer(&mut *stream, response)?;
|
||
stream.write_all(b"\n")?;
|
||
stream.flush()?;
|
||
Ok(())
|
||
}
|
||
|
||
fn json_rpc_result(id: Option<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_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)
|
||
}
|
||
|
||
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
|
||
));
|
||
}
|
||
Ok(response.result.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(());
|
||
}
|
||
}
|
||
|
||
let pid_path = daemon_pid_path(state_dir);
|
||
let Some(_pid) = read_pid_file(&pid_path)? else {
|
||
let report = stop_daemon_inner(state_dir)?;
|
||
print_report(output_format, &report)?;
|
||
return Ok(());
|
||
};
|
||
|
||
let report = stop_daemon_inner(state_dir)?;
|
||
print_report(output_format, &report)?;
|
||
Ok(())
|
||
}
|
||
|
||
fn stop_daemon_inner(state_dir: &Path) -> anyhow::Result<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);
|
||
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);
|
||
checks.push(DoctorCheck {
|
||
name: "daemon_status",
|
||
ok: match read_daemon_status_file(&status_path) {
|
||
Ok(Some(_)) | Ok(None) => true,
|
||
Err(_) => false,
|
||
},
|
||
message: if status_path.exists() {
|
||
format!("后台状态文件可解析:{}", status_path.display())
|
||
} else {
|
||
"后台状态文件尚未创建".to_string()
|
||
},
|
||
});
|
||
|
||
let healthy = checks.iter().all(|check| check.ok);
|
||
let report = DoctorReport {
|
||
command: "doctor",
|
||
status: if healthy { "ok" } else { "issues_found" },
|
||
message: if healthy {
|
||
"运行时诊断通过"
|
||
} else {
|
||
"运行时诊断发现问题"
|
||
},
|
||
healthy,
|
||
checks,
|
||
};
|
||
print_report(options.output_format, &report)?;
|
||
Ok(healthy)
|
||
}
|
||
|
||
fn path_check(name: &'static str, path: &Path, ready_message: &str) -> DoctorCheck {
|
||
let (ok, message) = if path.is_dir() {
|
||
(true, format!("{ready_message}:{}", path.display()))
|
||
} else if path.exists() {
|
||
(false, format!("路径存在但不是目录:{}", path.display()))
|
||
} else {
|
||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||
(
|
||
parent.is_dir(),
|
||
if parent.is_dir() {
|
||
format!(
|
||
"目录尚未创建,但父目录可用,将在首次运行时创建:{}",
|
||
path.display()
|
||
)
|
||
} else {
|
||
format!("目录不存在且父目录不可用:{}", path.display())
|
||
},
|
||
)
|
||
};
|
||
DoctorCheck { name, ok, message }
|
||
}
|
||
|
||
fn safety_check(name: &'static str, result: Result<(), String>, ok_message: &str) -> DoctorCheck {
|
||
match result {
|
||
Ok(()) => DoctorCheck {
|
||
name,
|
||
ok: true,
|
||
message: ok_message.to_string(),
|
||
},
|
||
Err(error) => DoctorCheck {
|
||
name,
|
||
ok: false,
|
||
message: error,
|
||
},
|
||
}
|
||
}
|
||
|
||
fn command_check(name: &'static str, command: &Path) -> DoctorCheck {
|
||
let result = Command::new(command).arg("--version").output();
|
||
DoctorCheck {
|
||
name,
|
||
ok: result.is_ok(),
|
||
message: match result {
|
||
Ok(output) => format!(
|
||
"命令可执行:{}(退出码={:?})",
|
||
command.display(),
|
||
output.status.code()
|
||
),
|
||
Err(error) => format!("命令不可执行 {}:{error}", command.display()),
|
||
},
|
||
}
|
||
}
|
||
|
||
fn proxy_check(config: &CurlProxyConfig) -> DoctorCheck {
|
||
let resolved = resolve_curl_proxy(config);
|
||
// 对解析出的代理 URL(含 auto 模式从环境变量取得的)做 scheme 校验,
|
||
// 让 doctor 能在前置阶段暴露拼错的代理配置,而非恒为 ok。
|
||
let (ok, message) = match resolved.url.as_deref() {
|
||
Some(url) => match validate_proxy_url(url) {
|
||
Ok(()) => (true, resolved.human_summary()),
|
||
Err(error) => (false, format!("{};{error}", resolved.human_summary())),
|
||
},
|
||
None => (true, resolved.human_summary()),
|
||
};
|
||
DoctorCheck {
|
||
name: "proxy",
|
||
ok,
|
||
message,
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
struct CleanStableReport {
|
||
command: &'static str,
|
||
status: &'static str,
|
||
message: &'static str,
|
||
output_root: PathBuf,
|
||
state_dir: PathBuf,
|
||
removed_paths: Vec<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)?))
|
||
}
|
||
|
||
fn write_daemon_status_file(path: &Path, status: &DaemonStatusFile) -> anyhow::Result<()> {
|
||
write_file_atomic(
|
||
path,
|
||
&serde_json::to_vec_pretty(status)?,
|
||
PRIVATE_FILE_MODE,
|
||
"后台状态文件",
|
||
)
|
||
.map_err(anyhow::Error::msg)?;
|
||
Ok(())
|
||
}
|
||
|
||
fn update_daemon_status(state_dir: &Path, update: DaemonStatusUpdate<'_>) -> anyhow::Result<()> {
|
||
let status_path = daemon_status_path(state_dir);
|
||
let mut status = read_daemon_status_file(&status_path)?.unwrap_or_else(|| DaemonStatusFile {
|
||
version: DAEMON_STATUS_VERSION,
|
||
pid: std::process::id(),
|
||
state: "started".to_string(),
|
||
resource_output_root: OfficialUpdateConfig::default().output_root,
|
||
state_dir: state_dir.to_path_buf(),
|
||
log_path: daemon_log_path(state_dir),
|
||
structured_log_path: Some(daemon_structured_log_path(state_dir)),
|
||
started_unix_seconds: unix_seconds_now(),
|
||
updated_unix_seconds: unix_seconds_now(),
|
||
last_success_unix_seconds: None,
|
||
next_check_unix_seconds: None,
|
||
last_update_status: None,
|
||
last_error: None,
|
||
next_retry_seconds: None,
|
||
current_stage: None,
|
||
current_message: None,
|
||
download_progress: None,
|
||
pending_scheduled_force: false,
|
||
next_forced_refresh_unix_seconds: None,
|
||
command: env::args().collect(),
|
||
});
|
||
status.pid = std::process::id();
|
||
status.state = update.state.to_string();
|
||
status.updated_unix_seconds = unix_seconds_now();
|
||
status.last_update_status = update.last_update_status;
|
||
status.last_error = update.last_error;
|
||
status.next_retry_seconds = update.next_retry_seconds;
|
||
if update.last_success_unix_seconds.is_some() {
|
||
status.last_success_unix_seconds = update.last_success_unix_seconds;
|
||
}
|
||
status.next_check_unix_seconds = update.next_check_unix_seconds;
|
||
if update.state != "running" {
|
||
status.current_stage = None;
|
||
status.current_message = None;
|
||
status.download_progress = None;
|
||
}
|
||
status.pending_scheduled_force = update.pending_scheduled_force;
|
||
status.next_forced_refresh_unix_seconds =
|
||
system_time_to_unix_seconds(update.next_forced_refresh_at);
|
||
write_daemon_status_file(&status_path, &status)
|
||
}
|
||
|
||
fn update_daemon_progress(state_dir: &Path, event: &OfficialUpdateProgress) -> anyhow::Result<()> {
|
||
let status_path = daemon_status_path(state_dir);
|
||
let Some(mut status) = read_daemon_status_file(&status_path)? else {
|
||
return Ok(());
|
||
};
|
||
status.pid = std::process::id();
|
||
status.updated_unix_seconds = unix_seconds_now();
|
||
status.current_stage = Some(event.stage.to_string());
|
||
status.current_message = Some(event.message.clone());
|
||
status.download_progress = match (event.download_index, event.download_total) {
|
||
(Some(index), Some(total)) => Some(DaemonDownloadProgress {
|
||
index,
|
||
total,
|
||
url: event.download_url.clone().unwrap_or_default(),
|
||
status: event.download_status.clone(),
|
||
bytes: event.download_bytes,
|
||
transferred_bytes: event.download_transferred_bytes,
|
||
failure_kind: event.download_failure_kind.clone(),
|
||
failure_http_status: event.download_failure_http_status,
|
||
failure_retryable: event.download_failure_retryable,
|
||
failure_attempts: event.download_failure_attempts,
|
||
quarantined: event.download_quarantined,
|
||
}),
|
||
_ if event.stage != "download" => None,
|
||
_ => status.download_progress,
|
||
};
|
||
write_daemon_status_file(&status_path, &status)
|
||
}
|
||
|
||
fn update_daemon_state_only(state_dir: &Path, state: &str) -> anyhow::Result<()> {
|
||
let status_path = daemon_status_path(state_dir);
|
||
let Some(mut status) = read_daemon_status_file(&status_path)? else {
|
||
return Ok(());
|
||
};
|
||
status.pid = std::process::id();
|
||
status.state = state.to_string();
|
||
status.updated_unix_seconds = unix_seconds_now();
|
||
status.next_retry_seconds = None;
|
||
write_daemon_status_file(&status_path, &status)
|
||
}
|
||
|
||
fn read_pid_file(path: &Path) -> anyhow::Result<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(output_root: &Path) -> PathBuf {
|
||
output_root.join(DAEMON_PID_FILE)
|
||
}
|
||
|
||
fn daemon_status_path(output_root: &Path) -> PathBuf {
|
||
output_root.join(DAEMON_STATUS_FILE)
|
||
}
|
||
|
||
fn daemon_log_path(output_root: &Path) -> PathBuf {
|
||
output_root.join(DAEMON_LOG_FILE)
|
||
}
|
||
|
||
fn daemon_structured_log_path(output_root: &Path) -> PathBuf {
|
||
output_root.join(DAEMON_STRUCTURED_LOG_FILE)
|
||
}
|
||
|
||
fn rotated_structured_log_path(path: &Path, index: usize) -> PathBuf {
|
||
let file_name = path
|
||
.file_name()
|
||
.and_then(|value| value.to_str())
|
||
.unwrap_or(DAEMON_STRUCTURED_LOG_FILE);
|
||
path.with_file_name(format!("{file_name}.{index}"))
|
||
}
|
||
|
||
fn rotated_structured_log_paths(path: &Path) -> Vec<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(output_root: &Path) -> PathBuf {
|
||
output_root.join(DAEMON_SOCKET_FILE)
|
||
}
|
||
|
||
fn daemon_control_lock_path(output_root: &Path) -> PathBuf {
|
||
output_root.join(DAEMON_CONTROL_LOCK_FILE)
|
||
}
|
||
|
||
fn daemon_proxy_secret_path(state_dir: &Path) -> PathBuf {
|
||
state_dir.join(DAEMON_PROXY_SECRET_FILE)
|
||
}
|
||
|
||
/// 返回 URL 型代理的凭据字符串;auto/disabled 模式返回 None。
|
||
fn curl_proxy_url(config: &CurlProxyConfig) -> Option<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)
|
||
}
|
||
}
|
||
|
||
#[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 localize_error_message(message: &str) -> String {
|
||
message.to_string()
|
||
}
|
||
|
||
fn print_startup_banner() {
|
||
eprintln!("{STARTUP_BANNER}");
|
||
}
|
||
|
||
fn should_print_status(status: OfficialUpdateStatus, quiet_up_to_date: bool) -> bool {
|
||
!(quiet_up_to_date && status == OfficialUpdateStatus::UpToDate)
|
||
}
|
||
|
||
fn parse_args() -> anyhow::Result<CliOptions> {
|
||
parse_args_from(env::args())
|
||
}
|
||
|
||
fn parse_args_from(raw_args: impl IntoIterator<Item = 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();
|
||
|
||
while let Some(flag) = args.next() {
|
||
match flag.as_str() {
|
||
"status" => {
|
||
ensure_command_not_set(options.command, "status")?;
|
||
options.command = CliCommand::Status;
|
||
}
|
||
"stop" => {
|
||
ensure_command_not_set(options.command, "stop")?;
|
||
options.command = CliCommand::Stop;
|
||
}
|
||
"restart" => {
|
||
ensure_command_not_set(options.command, "restart")?;
|
||
options.command = CliCommand::Restart;
|
||
}
|
||
"reload" => {
|
||
ensure_command_not_set(options.command, "reload")?;
|
||
options.command = CliCommand::Reload;
|
||
}
|
||
"refresh" => {
|
||
ensure_command_not_set(options.command, "refresh")?;
|
||
options.command = CliCommand::Refresh;
|
||
}
|
||
"verify" => {
|
||
ensure_command_not_set(options.command, "verify")?;
|
||
options.command = CliCommand::Verify;
|
||
}
|
||
"repair" => {
|
||
ensure_command_not_set(options.command, "repair")?;
|
||
options.command = CliCommand::Repair;
|
||
}
|
||
"doctor" => {
|
||
ensure_command_not_set(options.command, "doctor")?;
|
||
options.command = CliCommand::Doctor;
|
||
}
|
||
"logs" => {
|
||
ensure_command_not_set(options.command, "logs")?;
|
||
options.command = CliCommand::Logs;
|
||
}
|
||
"clean-stable" => {
|
||
ensure_command_not_set(options.command, "clean-stable")?;
|
||
options.command = CliCommand::CleanStable;
|
||
}
|
||
"--auto-discover" => {
|
||
options.config.auto_discover = true;
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--server-info-url" => {
|
||
options.config.server_info_source = Some(OfficialServerInfoSource::OfficialUrl(
|
||
next_option_value(&mut args, &flag)?,
|
||
));
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--server-info-file" => {
|
||
options.config.server_info_source = Some(OfficialServerInfoSource::OfficialFile(
|
||
next_option_value(&mut args, &flag)?,
|
||
));
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--server-info-path" => {
|
||
options.config.server_info_source = Some(OfficialServerInfoSource::LocalPath(
|
||
PathBuf::from(next_option_value(&mut args, &flag)?),
|
||
));
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--connection-group" => {
|
||
options.config.connection_group = Some(next_option_value(&mut args, &flag)?);
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--app-version" => {
|
||
options.config.app_version = Some(next_option_value(&mut args, &flag)?);
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--launcher-version" => {
|
||
options.config.launcher_version = next_option_value(&mut args, &flag)?;
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--platforms" => {
|
||
let value = next_option_value(&mut args, &flag)?;
|
||
options.config.platforms =
|
||
Some(parse_platforms(&value).map_err(anyhow::Error::msg)?);
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--output" => {
|
||
options.config.output_root = PathBuf::from(next_option_value(&mut args, &flag)?);
|
||
options.output_explicit = true;
|
||
}
|
||
"--state-dir" | "--pid-dir" => {
|
||
options.state_dir = PathBuf::from(next_option_value(&mut args, &flag)?);
|
||
}
|
||
"--snapshot" => {
|
||
options.config.snapshot_path =
|
||
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--curl" => {
|
||
options.config.curl_command = PathBuf::from(next_option_value(&mut args, &flag)?);
|
||
}
|
||
"--proxy" => {
|
||
options.config.curl_proxy =
|
||
parse_proxy_config(&next_option_value(&mut args, &flag)?)?;
|
||
options.proxy_option_explicit = true;
|
||
}
|
||
"--no-proxy" => {
|
||
options.config.curl_proxy = CurlProxyConfig::disabled();
|
||
options.proxy_option_explicit = true;
|
||
}
|
||
"--proxy-from-env" => {
|
||
// 后台子进程内部 flag:代理凭据从环境变量读取,随后立即清除,
|
||
// 避免被 curl 等孙进程继承。
|
||
let url = env::var(PROXY_URL_ENV_VAR).map_err(|_| {
|
||
anyhow::anyhow!("{PROXY_FROM_ENV_FLAG} 需要设置 {PROXY_URL_ENV_VAR} 环境变量")
|
||
})?;
|
||
env::remove_var(PROXY_URL_ENV_VAR);
|
||
options.config.curl_proxy = CurlProxyConfig::url(url);
|
||
options.proxy_option_explicit = true;
|
||
}
|
||
"--unzip" => {
|
||
options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?);
|
||
}
|
||
"--dry-run" => {
|
||
options.config.dry_run = true;
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--plan" => {
|
||
options.config.plan = true;
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--force" => {
|
||
options.config.force = true;
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--audit-local" => {
|
||
options.config.audit_local = true;
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--no-audit-local" => {
|
||
options.config.audit_local = false;
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--repair" => {
|
||
options.config.repair = true;
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--no-repair" => {
|
||
options.config.repair = false;
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--watch" => {
|
||
options.watch = true;
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--daemon" => {
|
||
options.daemon = true;
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--daemon-child" => {
|
||
options.daemon_child = true;
|
||
options.watch = true;
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--interval" => {
|
||
options.interval = parse_duration(&next_option_value(&mut args, &flag)?)?;
|
||
options.sync_option_explicit = true;
|
||
}
|
||
"--interval-seconds" => {
|
||
let seconds = next_option_value(&mut args, &flag)?
|
||
.parse::<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}")),
|
||
}
|
||
}
|
||
|
||
match options.command {
|
||
CliCommand::Status | CliCommand::Stop | CliCommand::Logs => {
|
||
if options.sync_option_explicit
|
||
|| options.output_explicit
|
||
|| options.proxy_option_explicit
|
||
|| tools_are_non_default(&options.config)
|
||
{
|
||
return Err(anyhow::anyhow!(
|
||
"status/stop/logs 只读取 --state-dir;资源同步参数和输出目录无关"
|
||
));
|
||
}
|
||
options.progress = false;
|
||
options.banner = false;
|
||
}
|
||
CliCommand::Doctor | CliCommand::CleanStable => {
|
||
if options.sync_option_explicit {
|
||
return Err(anyhow::anyhow!(
|
||
"doctor/clean-stable 只接受 --output、--state-dir、--curl、--proxy 和 --unzip 等诊断参数"
|
||
));
|
||
}
|
||
options.progress = false;
|
||
options.banner = false;
|
||
}
|
||
CliCommand::Refresh | CliCommand::Verify | CliCommand::Repair => {
|
||
if !options.config.auto_discover
|
||
&& options.config.server_info_source.is_none()
|
||
&& options.config.connection_group.is_none()
|
||
&& options.config.app_version.is_none()
|
||
{
|
||
options.config.auto_discover = true;
|
||
}
|
||
if matches!(options.command, CliCommand::Verify) {
|
||
options.config.dry_run = true;
|
||
options.config.plan = true;
|
||
options.config.audit_local = true;
|
||
options.config.repair = false;
|
||
}
|
||
}
|
||
CliCommand::Restart | CliCommand::Reload => {
|
||
if (options.sync_option_explicit
|
||
|| options.output_explicit
|
||
|| options.proxy_option_explicit
|
||
|| tools_are_non_default(&options.config))
|
||
&& !options.config.auto_discover
|
||
&& options.config.server_info_source.is_none()
|
||
&& options.config.connection_group.is_none()
|
||
&& options.config.app_version.is_none()
|
||
{
|
||
options.config.auto_discover = true;
|
||
}
|
||
}
|
||
CliCommand::Run => {}
|
||
}
|
||
if options.daemon && options.watch {
|
||
return Err(anyhow::anyhow!(
|
||
"--daemon 会自动启动 watch 模式,不要同时传 --watch"
|
||
));
|
||
}
|
||
if (options.watch || options.daemon || options.daemon_child) && options.config.dry_run {
|
||
return Err(anyhow::anyhow!(
|
||
"watch/daemon 模式不能和 --dry-run 同时使用"
|
||
));
|
||
}
|
||
if options.interval.is_zero() {
|
||
return Err(anyhow::anyhow!("watch 检查间隔必须大于 0"));
|
||
}
|
||
if options.error_retry_interval.is_zero() {
|
||
return Err(anyhow::anyhow!("watch 失败重试间隔必须大于 0"));
|
||
}
|
||
if (options.watch || options.daemon || options.daemon_child)
|
||
&& !options.quiet_up_to_date_explicit
|
||
{
|
||
options.quiet_up_to_date = true;
|
||
}
|
||
if matches!(options.command, CliCommand::Verify) {
|
||
if options.config.force {
|
||
return Err(anyhow::anyhow!(
|
||
"verify 只验证本地资源,不能和 --force 同时使用"
|
||
));
|
||
}
|
||
if !options.config.repair && !options.config.audit_local {
|
||
return Err(anyhow::anyhow!(
|
||
"verify 必须启用本地审计;不要同时传 --no-repair --no-audit-local"
|
||
));
|
||
}
|
||
}
|
||
if matches!(options.command, CliCommand::Restart | CliCommand::Reload)
|
||
&& (options.config.force || options.config.dry_run)
|
||
{
|
||
return Err(anyhow::anyhow!(
|
||
"restart/reload 不能和 --force 或 --dry-run 同时使用"
|
||
));
|
||
}
|
||
if matches!(options.command, CliCommand::Logs) && options.tail_lines == 0 {
|
||
return Err(anyhow::anyhow!("logs 的 --tail 必须大于 0"));
|
||
}
|
||
|
||
Ok(options)
|
||
}
|
||
|
||
fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()> {
|
||
if command == CliCommand::Run {
|
||
Ok(())
|
||
} else {
|
||
Err(anyhow::anyhow!("不支持同时指定多个命令;意外命令:{next}"))
|
||
}
|
||
}
|
||
|
||
fn tools_are_non_default(config: &OfficialUpdateConfig) -> bool {
|
||
let defaults = OfficialUpdateConfig::default();
|
||
config.curl_command != defaults.curl_command
|
||
|| config.curl_proxy != defaults.curl_proxy
|
||
|| config.unzip_command != defaults.unzip_command
|
||
}
|
||
|
||
fn next_option_value(
|
||
args: &mut impl Iterator<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!(" --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()
|
||
}
|
||
|
||
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()))
|
||
}
|
||
|
||
#[test]
|
||
fn parses_auto_discover_sync_args() {
|
||
let options = parse(&[
|
||
"bat",
|
||
"--auto-discover",
|
||
"--platforms",
|
||
"Windows,Android",
|
||
"--snapshot",
|
||
"/tmp/snapshot.json",
|
||
"--dry-run",
|
||
"--plan",
|
||
])
|
||
.unwrap();
|
||
let config = options.config;
|
||
|
||
assert!(config.auto_discover);
|
||
assert_eq!(
|
||
config.platforms.as_deref(),
|
||
Some([PatchPlatform::Windows, PatchPlatform::Android].as_slice())
|
||
);
|
||
assert_eq!(
|
||
config.snapshot_path,
|
||
Some(PathBuf::from("/tmp/snapshot.json"))
|
||
);
|
||
assert!(config.dry_run);
|
||
assert!(config.plan);
|
||
assert!(config.audit_local);
|
||
assert!(config.repair);
|
||
assert!(!options.watch);
|
||
assert_eq!(
|
||
options.interval,
|
||
Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS)
|
||
);
|
||
assert_eq!(
|
||
options.error_retry_interval,
|
||
Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS)
|
||
);
|
||
assert!(!options.quiet_up_to_date);
|
||
assert!(!options.quiet_up_to_date_explicit);
|
||
assert!(options.progress);
|
||
assert!(options.banner);
|
||
assert_eq!(options.output_format, OutputFormat::Human);
|
||
assert_eq!(config.output_root, PathBuf::from("./bat-resources"));
|
||
assert_eq!(options.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR));
|
||
}
|
||
|
||
#[test]
|
||
fn parses_explicit_source_and_disable_repair() {
|
||
let options = parse(&[
|
||
"bat",
|
||
"--server-info-url",
|
||
"https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json",
|
||
"--connection-group",
|
||
"Prod-Audit",
|
||
"--app-version",
|
||
"1.70.0",
|
||
"--no-audit-local",
|
||
"--no-repair",
|
||
"--unzip",
|
||
"/usr/bin/unzip",
|
||
])
|
||
.unwrap();
|
||
let config = options.config;
|
||
|
||
assert!(!config.auto_discover);
|
||
assert_eq!(config.connection_group.as_deref(), Some("Prod-Audit"));
|
||
assert_eq!(config.app_version.as_deref(), Some("1.70.0"));
|
||
assert!(!config.audit_local);
|
||
assert!(!config.repair);
|
||
assert_eq!(config.unzip_command, PathBuf::from("/usr/bin/unzip"));
|
||
assert!(matches!(
|
||
config.server_info_source,
|
||
Some(OfficialServerInfoSource::OfficialUrl(ref url))
|
||
if url == "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json"
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn parses_proxy_options() {
|
||
let options = parse(&["bat", "--proxy", "http://127.0.0.1:7890"]).unwrap();
|
||
assert!(options.proxy_option_explicit);
|
||
assert_eq!(
|
||
options.config.curl_proxy.mode(),
|
||
&CurlProxyMode::Url("http://127.0.0.1:7890".to_string())
|
||
);
|
||
|
||
let options = parse(&["bat", "--proxy", "none"]).unwrap();
|
||
assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Disabled);
|
||
|
||
let options = parse(&["bat", "--no-proxy"]).unwrap();
|
||
assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Disabled);
|
||
|
||
let options = parse(&["bat", "--proxy", "auto"]).unwrap();
|
||
assert!(options.proxy_option_explicit);
|
||
assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Auto);
|
||
|
||
// socks5 等受支持 scheme 可用。
|
||
let options = parse(&["bat", "--proxy", "socks5://127.0.0.1:1080"]).unwrap();
|
||
assert_eq!(
|
||
options.config.curl_proxy.mode(),
|
||
&CurlProxyMode::Url("socks5://127.0.0.1:1080".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_proxy_with_unsupported_scheme() {
|
||
let error = parse(&["bat", "--proxy", "htp://127.0.0.1:7890"]).unwrap_err();
|
||
assert!(error.to_string().contains("不支持的 scheme"));
|
||
|
||
// 无 scheme 的 host:port 仍接受(curl 默认按 http 处理)。
|
||
assert!(parse(&["bat", "--proxy", "127.0.0.1:7890"]).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn parses_watch_defaults_to_one_hour_and_quiet_up_to_date() {
|
||
let options = parse(&["bat", "--auto-discover", "--watch", "--interval", "30m"]).unwrap();
|
||
|
||
assert!(options.watch);
|
||
assert_eq!(options.interval, Duration::from_secs(30 * 60));
|
||
assert_eq!(
|
||
options.error_retry_interval,
|
||
Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS)
|
||
);
|
||
assert!(options.quiet_up_to_date);
|
||
assert!(!options.quiet_up_to_date_explicit);
|
||
}
|
||
|
||
#[test]
|
||
fn parses_daemon_mode_as_background_watch() {
|
||
let options = parse(&[
|
||
"bat",
|
||
"--auto-discover",
|
||
"--daemon",
|
||
"--output",
|
||
"/tmp/daemon-output",
|
||
"--interval",
|
||
"30m",
|
||
])
|
||
.unwrap();
|
||
|
||
assert_eq!(options.command, CliCommand::Run);
|
||
assert!(options.daemon);
|
||
assert!(!options.watch);
|
||
assert_eq!(
|
||
options.config.output_root,
|
||
PathBuf::from("/tmp/daemon-output")
|
||
);
|
||
assert_eq!(options.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR));
|
||
assert_eq!(options.interval, Duration::from_secs(30 * 60));
|
||
assert!(options.quiet_up_to_date);
|
||
}
|
||
|
||
#[test]
|
||
fn parses_output_format_flags() {
|
||
let options = parse(&["bat", "status", "--json"]).unwrap();
|
||
assert_eq!(options.command, CliCommand::Status);
|
||
assert_eq!(options.output_format, OutputFormat::Json);
|
||
|
||
let options = parse(&["bat", "status", "--json", "--human"]).unwrap();
|
||
assert_eq!(options.output_format, OutputFormat::Human);
|
||
}
|
||
|
||
#[test]
|
||
fn parses_status_and_stop_commands() {
|
||
let status = parse(&["bat", "status"]).unwrap();
|
||
assert_eq!(status.command, CliCommand::Status);
|
||
assert!(!status.watch);
|
||
assert!(!status.daemon);
|
||
assert!(!status.progress);
|
||
assert!(!status.banner);
|
||
assert_eq!(status.config.output_root, PathBuf::from("./bat-resources"));
|
||
assert_eq!(status.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR));
|
||
|
||
let stop = parse(&["bat", "stop", "--state-dir", "/tmp/custom-bat-pid"]).unwrap();
|
||
assert_eq!(stop.command, CliCommand::Stop);
|
||
assert_eq!(stop.config.output_root, PathBuf::from("./bat-resources"));
|
||
assert_eq!(stop.state_dir, PathBuf::from("/tmp/custom-bat-pid"));
|
||
}
|
||
|
||
#[test]
|
||
fn parses_management_and_resource_commands() {
|
||
let refresh = parse(&["bat", "refresh", "--force"]).unwrap();
|
||
assert_eq!(refresh.command, CliCommand::Refresh);
|
||
assert!(refresh.config.auto_discover);
|
||
assert!(refresh.config.force);
|
||
|
||
let verify = parse(&["bat", "verify"]).unwrap();
|
||
assert_eq!(verify.command, CliCommand::Verify);
|
||
assert!(verify.config.auto_discover);
|
||
assert!(verify.config.dry_run);
|
||
assert!(verify.config.plan);
|
||
assert!(!verify.config.repair);
|
||
|
||
let repair = parse(&["bat", "repair"]).unwrap();
|
||
assert_eq!(repair.command, CliCommand::Repair);
|
||
assert!(repair.config.auto_discover);
|
||
|
||
for (value, expected) in [
|
||
("restart", CliCommand::Restart),
|
||
("reload", CliCommand::Reload),
|
||
("logs", CliCommand::Logs),
|
||
("doctor", CliCommand::Doctor),
|
||
("clean-stable", CliCommand::CleanStable),
|
||
] {
|
||
let options = parse(&["bat", value]).unwrap();
|
||
assert_eq!(options.command, expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parses_logs_tail_and_rejects_force_for_verify() {
|
||
let options = parse(&["bat", "logs", "--tail", "25"]).unwrap();
|
||
assert_eq!(options.tail_lines, 25);
|
||
|
||
let error = parse(&["bat", "verify", "--force"]).unwrap_err();
|
||
assert!(error.to_string().contains("不能和 --force"));
|
||
}
|
||
|
||
#[test]
|
||
fn restart_with_explicit_options_defaults_to_auto_discover() {
|
||
let options = parse(&["bat", "restart", "--output", "/tmp/bat-resources"]).unwrap();
|
||
|
||
assert_eq!(options.command, CliCommand::Restart);
|
||
assert!(options.output_explicit);
|
||
assert!(options.config.auto_discover);
|
||
}
|
||
|
||
#[test]
|
||
fn daemon_child_args_preserve_sync_options() {
|
||
let options = parse(&[
|
||
"bat",
|
||
"--auto-discover",
|
||
"--daemon",
|
||
"--platforms",
|
||
"Windows,Android",
|
||
"--output",
|
||
"/tmp/daemon-output",
|
||
"--state-dir",
|
||
"/tmp/daemon-state",
|
||
"--curl",
|
||
"/usr/bin/curl",
|
||
"--proxy",
|
||
"http://127.0.0.1:7890",
|
||
"--unzip",
|
||
"/usr/bin/unzip",
|
||
"--interval",
|
||
"30m",
|
||
"--error-retry",
|
||
"45s",
|
||
"--json",
|
||
"--no-banner",
|
||
])
|
||
.unwrap();
|
||
|
||
let args = daemon_child_args(&options);
|
||
assert!(args.contains(&"--watch".to_string()));
|
||
assert!(args.contains(&"--daemon-child".to_string()));
|
||
assert!(!args.contains(&"--daemon".to_string()));
|
||
assert!(args
|
||
.windows(2)
|
||
.any(|pair| pair == ["--output", "/tmp/daemon-output"]));
|
||
assert!(args
|
||
.windows(2)
|
||
.any(|pair| pair == ["--state-dir", "/tmp/daemon-state"]));
|
||
assert!(args
|
||
.windows(2)
|
||
.any(|pair| pair == ["--platforms", "Windows,Android"]));
|
||
// 代理凭据不得进入子进程 argv:只放不含凭据的 flag,URL 经环境变量下传。
|
||
assert!(args.contains(&PROXY_FROM_ENV_FLAG.to_string()));
|
||
assert!(!args.iter().any(|arg| arg.contains("127.0.0.1:7890")));
|
||
assert!(!args.windows(2).any(|pair| pair[0] == "--proxy"));
|
||
assert!(args.windows(2).any(|pair| pair == ["--interval", "30m"]));
|
||
assert!(args.windows(2).any(|pair| pair == ["--error-retry", "45s"]));
|
||
assert!(args.contains(&"--quiet-up-to-date".to_string()));
|
||
assert!(args.contains(&"--json".to_string()));
|
||
assert!(args.contains(&"--no-banner".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn curl_proxy_url_extracts_only_url_mode() {
|
||
assert_eq!(
|
||
curl_proxy_url(&CurlProxyConfig::url("http://user:secret@127.0.0.1:7890")),
|
||
Some("http://user:secret@127.0.0.1:7890".to_string())
|
||
);
|
||
assert_eq!(curl_proxy_url(&CurlProxyConfig::auto()), None);
|
||
assert_eq!(curl_proxy_url(&CurlProxyConfig::disabled()), None);
|
||
}
|
||
|
||
#[test]
|
||
fn daemon_proxy_secret_round_trip_and_clear() {
|
||
let dir = tempfile::TempDir::new().unwrap();
|
||
let state_dir = dir.path();
|
||
|
||
assert_eq!(read_daemon_proxy_secret(state_dir).unwrap(), None);
|
||
|
||
write_daemon_proxy_secret(state_dir, "http://user:secret@127.0.0.1:7890").unwrap();
|
||
assert_eq!(
|
||
read_daemon_proxy_secret(state_dir).unwrap(),
|
||
Some("http://user:secret@127.0.0.1:7890".to_string())
|
||
);
|
||
// 凭据文件以 0600 创建。
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let mode = fs::metadata(daemon_proxy_secret_path(state_dir))
|
||
.unwrap()
|
||
.permissions()
|
||
.mode()
|
||
& 0o777;
|
||
assert_eq!(mode, 0o600);
|
||
}
|
||
|
||
clear_daemon_proxy_secret(state_dir);
|
||
assert_eq!(read_daemon_proxy_secret(state_dir).unwrap(), None);
|
||
}
|
||
|
||
// 序列化会修改进程级 PROXY_URL_ENV_VAR 的测试,避免并行测试相互干扰。
|
||
static PROXY_ENV_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||
|
||
#[test]
|
||
fn proxy_from_env_flag_reads_and_consumes_env_var() {
|
||
let _guard = PROXY_ENV_TEST_LOCK
|
||
.lock()
|
||
.unwrap_or_else(|poison| poison.into_inner());
|
||
env::set_var(PROXY_URL_ENV_VAR, "http://user:secret@127.0.0.1:7890");
|
||
let options = parse(&["bat", "--watch", "--proxy-from-env"]).unwrap();
|
||
assert!(options.proxy_option_explicit);
|
||
assert_eq!(
|
||
options.config.curl_proxy.mode(),
|
||
&CurlProxyMode::Url("http://user:secret@127.0.0.1:7890".to_string())
|
||
);
|
||
// 读取后应从环境中清除,避免被 curl 等孙进程继承。
|
||
assert!(env::var(PROXY_URL_ENV_VAR).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn proxy_from_env_flag_errors_when_env_var_missing() {
|
||
let _guard = PROXY_ENV_TEST_LOCK
|
||
.lock()
|
||
.unwrap_or_else(|poison| poison.into_inner());
|
||
env::remove_var(PROXY_URL_ENV_VAR);
|
||
let error = parse(&["bat", "--watch", "--proxy-from-env"]).unwrap_err();
|
||
assert!(error.to_string().contains(PROXY_URL_ENV_VAR));
|
||
}
|
||
|
||
#[test]
|
||
fn parses_explicit_watch_error_retry_interval() {
|
||
let options =
|
||
parse(&["bat", "--watch", "--interval", "1h", "--error-retry", "45s"]).unwrap();
|
||
assert_eq!(options.interval, Duration::from_secs(3600));
|
||
assert_eq!(options.error_retry_interval, Duration::from_secs(45));
|
||
|
||
let options = parse(&["bat", "--watch", "--error-retry-seconds", "30"]).unwrap();
|
||
assert_eq!(options.error_retry_interval, Duration::from_secs(30));
|
||
}
|
||
|
||
#[test]
|
||
fn parses_progress_flags() {
|
||
let options = parse(&["bat"]).unwrap();
|
||
assert!(options.progress);
|
||
assert!(options.banner);
|
||
|
||
let options = parse(&["bat", "--no-progress"]).unwrap();
|
||
assert!(!options.progress);
|
||
assert!(!options.banner);
|
||
|
||
let options = parse(&["bat", "--no-progress", "--progress"]).unwrap();
|
||
assert!(options.progress);
|
||
assert!(options.banner);
|
||
}
|
||
|
||
#[test]
|
||
fn parses_banner_flags() {
|
||
let options = parse(&["bat", "--no-banner"]).unwrap();
|
||
assert!(options.progress);
|
||
assert!(!options.banner);
|
||
|
||
let options = parse(&["bat", "--no-progress", "--banner"]).unwrap();
|
||
assert!(!options.progress);
|
||
assert!(options.banner);
|
||
}
|
||
|
||
#[test]
|
||
fn startup_banner_contains_product_name() {
|
||
assert!(STARTUP_BANNER.contains("BlueArchiveToolkit"));
|
||
}
|
||
|
||
#[test]
|
||
fn verification_summary_output_names_all_validation_types() {
|
||
let summary = OfficialVerificationSummary::new(7, 1, 2, 3);
|
||
let lines = verification_summary_lines(&summary);
|
||
|
||
assert_eq!(lines.len(), 3);
|
||
assert!(lines[0].contains("官方 .hash 强校验"));
|
||
assert!(lines[0].contains("xxHash32"));
|
||
assert!(lines[1].contains("本地 BLAKE3 复用校验"));
|
||
assert!(lines[1].contains("需修复"));
|
||
assert!(lines[2].contains("ZIP 结构校验"));
|
||
assert!(lines[2].contains("central directory"));
|
||
|
||
let json = serde_json::to_value(&summary).unwrap();
|
||
assert_eq!(json["official_hash_verified_count"], 2);
|
||
assert_eq!(json["local_manifest_blake3_verified_count"], 7);
|
||
assert_eq!(json["local_manifest_repair_needed_count"], 1);
|
||
assert_eq!(json["zip_structure_verified_count"], 3);
|
||
}
|
||
|
||
#[test]
|
||
fn daemon_rpc_download_progress_formats_as_human_text() {
|
||
let progress = serde_json::json!({
|
||
"index": 2,
|
||
"total": 19,
|
||
"url": "https://prod-clientpatch.bluearchiveyostar.com/file.zip",
|
||
"status": "finished",
|
||
"bytes": 100,
|
||
"transferred_bytes": 100
|
||
});
|
||
|
||
let formatted = format_daemon_download_progress_json(&progress).unwrap();
|
||
|
||
assert_eq!(
|
||
formatted,
|
||
"2/19 finished https://prod-clientpatch.bluearchiveyostar.com/file.zip"
|
||
);
|
||
assert!(!formatted.contains('{'));
|
||
}
|
||
|
||
#[test]
|
||
fn daemon_rpc_failed_download_progress_formats_as_human_text() {
|
||
let progress = serde_json::json!({
|
||
"index": 3,
|
||
"total": 19,
|
||
"url": "https://prod-clientpatch.bluearchiveyostar.com/missing.zip",
|
||
"status": "failed",
|
||
"failure_kind": "http_404",
|
||
"failure_http_status": 404,
|
||
"failure_retryable": false,
|
||
"failure_attempts": 1,
|
||
"quarantined": true
|
||
});
|
||
|
||
let formatted = format_daemon_download_progress_json(&progress).unwrap();
|
||
|
||
assert_eq!(
|
||
formatted,
|
||
"3/19 failed kind=http_404 http=404 retryable=false attempts=1 quarantined=true https://prod-clientpatch.bluearchiveyostar.com/missing.zip"
|
||
);
|
||
assert!(!formatted.contains('{'));
|
||
}
|
||
|
||
#[test]
|
||
fn daemon_status_hides_failure_superseded_by_in_progress_version() {
|
||
let version = OfficialVersionRecord {
|
||
id: "1.70.0-current-attempt".to_string(),
|
||
app_version: "1.70.0".to_string(),
|
||
bundle_version: Some("s8tloc7lo3".to_string()),
|
||
addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_token"
|
||
.to_string(),
|
||
resource_root: PathBuf::from("/tmp/bat/.staging/current"),
|
||
snapshot_path: PathBuf::from("/tmp/bat/.staging/current/official-sync-snapshot.json"),
|
||
staging_path: Some(PathBuf::from("/tmp/bat/.staging/current")),
|
||
version_path: None,
|
||
started_unix_seconds: Some(100),
|
||
completed_unix_seconds: None,
|
||
};
|
||
let older_failure = OfficialFailedVersionRecord {
|
||
version: OfficialVersionRecord {
|
||
id: "1.70.0-older-failure".to_string(),
|
||
resource_root: PathBuf::from("/tmp/bat/.staging/older"),
|
||
snapshot_path: PathBuf::from("/tmp/bat/.staging/older/official-sync-snapshot.json"),
|
||
staging_path: Some(PathBuf::from("/tmp/bat/.staging/older")),
|
||
started_unix_seconds: Some(90),
|
||
..version.clone()
|
||
},
|
||
error: "官方 hash 校验失败 fixture".to_string(),
|
||
failed_unix_seconds: 95,
|
||
};
|
||
let state = OfficialVersionState {
|
||
in_progress_version: Some(version),
|
||
failed_versions: vec![older_failure],
|
||
updated_unix_seconds: 100,
|
||
..OfficialVersionState::default()
|
||
};
|
||
|
||
assert!(visible_historical_failed_versions(&state).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn daemon_socket_path_uses_state_dir() {
|
||
assert_eq!(
|
||
daemon_socket_path(Path::new("/tmp/custom-bat-pid")),
|
||
PathBuf::from("/tmp/custom-bat-pid/bat.sock")
|
||
);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn active_official_resource_root_uses_current_symlink() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let output = temp.path().join("resources");
|
||
let version = output.join("versions").join("v1");
|
||
fs::create_dir_all(&version).unwrap();
|
||
symlink(Path::new("versions").join("v1"), output.join("current")).unwrap();
|
||
|
||
assert_eq!(
|
||
active_official_resource_root(&output).unwrap(),
|
||
lexical_absolute(&version).unwrap()
|
||
);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn daemon_rpc_rejects_socket_symlink() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let state_dir = temp.path().join("state");
|
||
fs::create_dir_all(&state_dir).unwrap();
|
||
let outside_socket = temp.path().join("outside.sock");
|
||
let socket_path = daemon_socket_path(&state_dir);
|
||
symlink(&outside_socket, &socket_path).unwrap();
|
||
|
||
assert!(daemon_socket_path_exists(&socket_path).unwrap());
|
||
assert!(!daemon_rpc_available(&state_dir));
|
||
let error = daemon_rpc_call(&state_dir, RPC_METHOD_STATUS, None).unwrap_err();
|
||
assert!(error.to_string().contains("symlink"));
|
||
}
|
||
|
||
#[test]
|
||
fn rpc_tail_param_defaults_and_rejects_zero() {
|
||
assert_eq!(rpc_tail_param(None, 200).unwrap(), 200);
|
||
assert_eq!(
|
||
rpc_tail_param(Some(&serde_json::json!({ "tail": 25 })), 200).unwrap(),
|
||
25
|
||
);
|
||
let error = rpc_tail_param(Some(&serde_json::json!({ "tail": 0 })), 200).unwrap_err();
|
||
assert!(error.to_string().contains("大于 0"));
|
||
}
|
||
|
||
#[test]
|
||
fn json_rpc_request_and_response_are_line_protocol_friendly() {
|
||
let request: JsonRpcRequest = serde_json::from_value(serde_json::json!({
|
||
"jsonrpc": "2.0",
|
||
"id": 7,
|
||
"method": "bat.refresh",
|
||
"params": { "force": true }
|
||
}))
|
||
.unwrap();
|
||
assert_eq!(request.method, RPC_METHOD_REFRESH);
|
||
assert_eq!(rpc_bool_param(request.params.as_ref(), "force"), Some(true));
|
||
|
||
let response = json_rpc_result(
|
||
Some(serde_json::json!(7)),
|
||
serde_json::json!({ "ok": true }),
|
||
);
|
||
let serialized = serde_json::to_string(&response).unwrap();
|
||
assert!(serialized.contains("\"jsonrpc\":\"2.0\""));
|
||
assert!(serialized.contains("\"ok\":true"));
|
||
assert!(!serialized.contains('\n'));
|
||
}
|
||
|
||
#[test]
|
||
fn daemon_control_wake_handles_refresh_reload_and_stop() {
|
||
let control = new_daemon_control();
|
||
daemon_control_request_refresh(&control, true);
|
||
assert_eq!(
|
||
wait_for_daemon_wake(Some(&control), Duration::from_millis(1)),
|
||
DaemonWake::Refresh { force: true }
|
||
);
|
||
|
||
daemon_control_request_reload(&control);
|
||
assert_eq!(
|
||
wait_for_daemon_wake(Some(&control), Duration::from_millis(1)),
|
||
DaemonWake::Reload
|
||
);
|
||
|
||
daemon_control_request_refresh(&control, false);
|
||
daemon_control_mark_stop_requested(&control);
|
||
daemon_control_notify_all(&control);
|
||
assert_eq!(
|
||
wait_for_daemon_wake(Some(&control), Duration::from_millis(1)),
|
||
DaemonWake::Stop
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn daemon_control_lock_rejects_active_owner() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let state_dir = temp.path().join("state");
|
||
|
||
let first = DaemonControlLock::acquire(&state_dir).unwrap();
|
||
let second = DaemonControlLock::acquire(&state_dir).unwrap_err();
|
||
assert!(second.to_string().contains("locked"));
|
||
drop(first);
|
||
assert!(DaemonControlLock::acquire(&state_dir).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn daemon_control_lock_recovers_stale_and_corrupt_files() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let state_dir = temp.path().join("state");
|
||
fs::create_dir_all(&state_dir).unwrap();
|
||
let lock_path = daemon_control_lock_path(&state_dir);
|
||
|
||
fs::write(&lock_path, "999999999").unwrap();
|
||
let lock = DaemonControlLock::acquire(&state_dir).unwrap();
|
||
drop(lock);
|
||
assert!(!lock_path.exists());
|
||
|
||
fs::write(&lock_path, "not-a-pid").unwrap();
|
||
let lock = DaemonControlLock::acquire(&state_dir).unwrap();
|
||
assert_eq!(
|
||
fs::read_to_string(&lock_path).unwrap(),
|
||
std::process::id().to_string()
|
||
);
|
||
drop(lock);
|
||
assert!(!lock_path.exists());
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn daemon_control_lock_file_is_private() {
|
||
use std::os::unix::fs::PermissionsExt;
|
||
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let state_dir = temp.path().join("state");
|
||
let lock_path = daemon_control_lock_path(&state_dir);
|
||
let lock = DaemonControlLock::acquire(&state_dir).unwrap();
|
||
|
||
let mode = fs::metadata(&lock_path).unwrap().permissions().mode() & 0o777;
|
||
assert_eq!(mode, 0o600);
|
||
drop(lock);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn daemon_status_file_is_private_and_rejects_symlink() {
|
||
use std::os::unix::fs::{symlink, PermissionsExt};
|
||
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let state_dir = temp.path().join("state");
|
||
let output_root = temp.path().join("resources");
|
||
fs::create_dir_all(&state_dir).unwrap();
|
||
fs::create_dir_all(&output_root).unwrap();
|
||
let status_path = daemon_status_path(&state_dir);
|
||
let status = test_daemon_status_file(&state_dir, &output_root);
|
||
|
||
write_daemon_status_file(&status_path, &status).unwrap();
|
||
let mode = fs::metadata(&status_path).unwrap().permissions().mode() & 0o777;
|
||
assert_eq!(mode, 0o600);
|
||
|
||
fs::remove_file(&status_path).unwrap();
|
||
let outside = temp.path().join("outside-status.json");
|
||
fs::write(&outside, b"outside").unwrap();
|
||
symlink(&outside, &status_path).unwrap();
|
||
|
||
let error = write_daemon_status_file(&status_path, &status).unwrap_err();
|
||
assert!(error.to_string().contains("symlink"));
|
||
assert_eq!(fs::read(&outside).unwrap(), b"outside");
|
||
}
|
||
|
||
#[test]
|
||
fn daemon_status_tracks_progress_success_and_next_check() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let state_dir = temp.path().join("state");
|
||
let output_root = temp.path().join("resources");
|
||
fs::create_dir_all(&state_dir).unwrap();
|
||
fs::create_dir_all(&output_root).unwrap();
|
||
write_daemon_status_file(
|
||
&daemon_status_path(&state_dir),
|
||
&test_daemon_status_file(&state_dir, &output_root),
|
||
)
|
||
.unwrap();
|
||
|
||
let mut event = OfficialUpdateProgress::new("download", "(2/19) 开始 fixture");
|
||
event.download_index = Some(2);
|
||
event.download_total = Some(19);
|
||
event.download_url = Some("https://prod-clientpatch.bluearchiveyostar.com/file.zip".into());
|
||
update_daemon_progress(&state_dir, &event).unwrap();
|
||
|
||
let report = build_daemon_status_report(&state_dir).unwrap();
|
||
assert_eq!(report.current_stage.as_deref(), Some("download"));
|
||
assert_eq!(report.download_progress.as_ref().unwrap().index, 2);
|
||
assert_eq!(report.download_progress.as_ref().unwrap().total, 19);
|
||
|
||
let next_check = unix_seconds_after(Duration::from_secs(30));
|
||
update_daemon_status(
|
||
&state_dir,
|
||
DaemonStatusUpdate {
|
||
state: "sleeping",
|
||
last_update_status: Some("downloaded".to_string()),
|
||
last_error: None,
|
||
next_retry_seconds: Some(30),
|
||
last_success_unix_seconds: Some(123456),
|
||
next_check_unix_seconds: Some(next_check),
|
||
pending_scheduled_force: false,
|
||
next_forced_refresh_at: SystemTime::now() + Duration::from_secs(3600),
|
||
},
|
||
)
|
||
.unwrap();
|
||
let report = build_daemon_status_report(&state_dir).unwrap();
|
||
assert_eq!(report.last_success_unix_seconds, Some(123456));
|
||
assert_eq!(report.next_check_unix_seconds, Some(next_check));
|
||
assert!(report.current_stage.is_none());
|
||
assert!(report.download_progress.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn structured_logger_writes_jsonl_and_rotates() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let path = temp.path().join("bat-events.jsonl");
|
||
let mut logger = RotatingStructuredLogger::new(path.clone(), 180, 2);
|
||
let mut event = OfficialUpdateProgress::new("download", "下载进度 fixture");
|
||
event.download_index = Some(1);
|
||
event.download_total = Some(2);
|
||
event.download_url = Some("https://prod-clientpatch.bluearchiveyostar.com/a.zip".into());
|
||
|
||
for _ in 0..8 {
|
||
logger
|
||
.write_event(Duration::from_millis(42), &event)
|
||
.unwrap();
|
||
}
|
||
|
||
assert!(path.exists());
|
||
assert!(rotated_structured_log_path(&path, 1).exists());
|
||
let line = fs::read_to_string(&path)
|
||
.unwrap()
|
||
.lines()
|
||
.next()
|
||
.unwrap()
|
||
.to_string();
|
||
let value: serde_json::Value = serde_json::from_str(&line).unwrap();
|
||
assert_eq!(value["stage"], "download");
|
||
assert_eq!(value["download"]["index"], 1);
|
||
assert_eq!(value["download"]["total"], 2);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn read_pid_file_rejects_symlink() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let state_dir = temp.path().join("state");
|
||
fs::create_dir_all(&state_dir).unwrap();
|
||
let outside = temp.path().join("outside.pid");
|
||
fs::write(&outside, b"123").unwrap();
|
||
let pid_path = daemon_pid_path(&state_dir);
|
||
symlink(&outside, &pid_path).unwrap();
|
||
|
||
let error = read_pid_file(&pid_path).unwrap_err();
|
||
assert!(error.to_string().contains("symlink"));
|
||
}
|
||
|
||
#[test]
|
||
fn live_daemon_resource_conflict_detects_same_output() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let state_dir = temp.path().join("state");
|
||
let output_root = temp.path().join("resources");
|
||
fs::create_dir_all(&output_root).unwrap();
|
||
write_daemon_status_file(
|
||
&daemon_status_path(&state_dir),
|
||
&test_daemon_status_file(&state_dir, &output_root),
|
||
)
|
||
.unwrap();
|
||
fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap();
|
||
|
||
let conflict = live_daemon_resource_conflict(&state_dir, &output_root)
|
||
.unwrap()
|
||
.unwrap();
|
||
assert_eq!(conflict.pid, Some(std::process::id()));
|
||
assert_eq!(
|
||
conflict.target_root,
|
||
normalized_abs_path(&output_root).unwrap()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn live_daemon_resource_conflict_allows_different_output() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let state_dir = temp.path().join("state");
|
||
let daemon_output = temp.path().join("daemon-resources");
|
||
let manual_output = temp.path().join("manual-resources");
|
||
fs::create_dir_all(&daemon_output).unwrap();
|
||
fs::create_dir_all(&manual_output).unwrap();
|
||
write_daemon_status_file(
|
||
&daemon_status_path(&state_dir),
|
||
&test_daemon_status_file(&state_dir, &daemon_output),
|
||
)
|
||
.unwrap();
|
||
fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap();
|
||
|
||
assert!(live_daemon_resource_conflict(&state_dir, &manual_output)
|
||
.unwrap()
|
||
.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn direct_write_rejects_live_daemon_for_same_output() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let state_dir = temp.path().join("state");
|
||
let output_root = temp.path().join("resources");
|
||
fs::create_dir_all(&output_root).unwrap();
|
||
write_daemon_status_file(
|
||
&daemon_status_path(&state_dir),
|
||
&test_daemon_status_file(&state_dir, &output_root),
|
||
)
|
||
.unwrap();
|
||
fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap();
|
||
let options = CliOptions {
|
||
state_dir,
|
||
config: OfficialUpdateConfig {
|
||
output_root,
|
||
..OfficialUpdateConfig::default()
|
||
},
|
||
..CliOptions::default()
|
||
};
|
||
|
||
let error = assert_no_live_daemon_output_conflict(&options, "repair").unwrap_err();
|
||
assert!(error.to_string().contains("同一资源目录"));
|
||
assert!(error.to_string().contains("locked"));
|
||
}
|
||
|
||
#[test]
|
||
fn clean_stable_removes_corrupt_pid_and_lock_files() {
|
||
let temp = tempfile::TempDir::new().unwrap();
|
||
let output_root = temp.path().join("resources");
|
||
let state_dir = temp.path().join("state");
|
||
fs::create_dir_all(&output_root).unwrap();
|
||
fs::create_dir_all(&state_dir).unwrap();
|
||
let resource_lock = output_root.join(".official-sync.lock");
|
||
let control_lock = daemon_control_lock_path(&state_dir);
|
||
let pid_path = daemon_pid_path(&state_dir);
|
||
fs::write(&resource_lock, "not-a-pid").unwrap();
|
||
fs::write(&control_lock, "not-a-pid").unwrap();
|
||
fs::write(&pid_path, "not-a-pid").unwrap();
|
||
|
||
let options = CliOptions {
|
||
command: CliCommand::CleanStable,
|
||
output_format: OutputFormat::Json,
|
||
state_dir,
|
||
config: OfficialUpdateConfig {
|
||
output_root,
|
||
..OfficialUpdateConfig::default()
|
||
},
|
||
..CliOptions::default()
|
||
};
|
||
run_clean_stable_command(&options).unwrap();
|
||
|
||
assert!(!resource_lock.exists());
|
||
assert!(!control_lock.exists());
|
||
assert!(!pid_path.exists());
|
||
}
|
||
|
||
#[test]
|
||
fn rpc_stop_wait_reports_already_stopped_without_signal() {
|
||
assert!(!wait_for_rpc_stop_or_terminate(0, Duration::from_millis(1)).unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn refresh_rpc_selection_only_for_default_daemon_shape() {
|
||
let options = parse(&["bat", "refresh"]).unwrap();
|
||
assert!(refresh_should_use_daemon_rpc(&options, "refresh"));
|
||
|
||
let options = parse(&["bat", "refresh", "--force"]).unwrap();
|
||
assert!(refresh_should_use_daemon_rpc(&options, "refresh"));
|
||
|
||
let options = parse(&["bat", "refresh", "--output", "/tmp/other"]).unwrap();
|
||
assert!(!refresh_should_use_daemon_rpc(&options, "refresh"));
|
||
|
||
let options = parse(&["bat", "refresh", "--server-info-file", "ProdNotice.json"]).unwrap();
|
||
assert!(!refresh_should_use_daemon_rpc(&options, "refresh"));
|
||
|
||
let options = parse(&["bat", "repair"]).unwrap();
|
||
assert!(!refresh_should_use_daemon_rpc(&options, "repair"));
|
||
}
|
||
|
||
#[test]
|
||
fn explicit_no_quiet_up_to_date_overrides_watch_default() {
|
||
let options = parse(&[
|
||
"bat",
|
||
"--watch",
|
||
"--no-quiet-up-to-date",
|
||
"--interval",
|
||
"1h",
|
||
])
|
||
.unwrap();
|
||
|
||
assert!(options.watch);
|
||
assert!(!options.quiet_up_to_date);
|
||
assert!(options.quiet_up_to_date_explicit);
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_watch_dry_run_and_zero_interval() {
|
||
let error = parse(&["bat", "--watch", "--dry-run"]).unwrap_err();
|
||
assert!(error.to_string().contains("不能和 --dry-run"));
|
||
|
||
let error = parse(&["bat", "--daemon", "--watch"]).unwrap_err();
|
||
assert!(error.to_string().contains("不要同时传 --watch"));
|
||
|
||
let error = parse(&["bat", "status", "--watch"]).unwrap_err();
|
||
assert!(error.to_string().contains("status/stop"));
|
||
|
||
let error = parse(&["bat", "status", "--output", "/tmp/resources"]).unwrap_err();
|
||
assert!(error.to_string().contains("--state-dir"));
|
||
|
||
let error = parse(&["bat", "--watch", "--interval-seconds", "0"]).unwrap_err();
|
||
assert!(error.to_string().contains("必须大于 0"));
|
||
|
||
let error = parse(&["bat", "--watch", "--error-retry-seconds", "0"]).unwrap_err();
|
||
assert!(error.to_string().contains("失败重试间隔"));
|
||
}
|
||
|
||
#[test]
|
||
fn parses_duration_units() {
|
||
assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
|
||
assert_eq!(parse_duration("60m").unwrap(), Duration::from_secs(3600));
|
||
assert_eq!(parse_duration("90s").unwrap(), Duration::from_secs(90));
|
||
assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250));
|
||
assert_eq!(parse_duration("3600").unwrap(), Duration::from_secs(3600));
|
||
}
|
||
|
||
#[test]
|
||
fn formats_duration_args_for_child_process() {
|
||
assert_eq!(format_duration_arg(Duration::from_secs(3600)), "1h");
|
||
assert_eq!(format_duration_arg(Duration::from_secs(90 * 60)), "90m");
|
||
assert_eq!(format_duration_arg(Duration::from_secs(45)), "45s");
|
||
assert_eq!(format_duration_arg(Duration::from_millis(250)), "250ms");
|
||
}
|
||
|
||
#[test]
|
||
fn scheduled_forced_refresh_uses_beijing_local_times() {
|
||
let day = 20_000;
|
||
|
||
assert_eq!(
|
||
next_forced_refresh_at_or_after(beijing_time(day, 2, 30, 0)),
|
||
beijing_time(day, 3, 0, 0)
|
||
);
|
||
assert_eq!(
|
||
next_forced_refresh_at_or_after(beijing_time(day, 3, 0, 0)),
|
||
beijing_time(day, 3, 0, 0)
|
||
);
|
||
assert_eq!(
|
||
next_forced_refresh_after(beijing_time(day, 3, 0, 0)),
|
||
beijing_time(day, 16, 0, 0)
|
||
);
|
||
assert_eq!(
|
||
next_forced_refresh_after(beijing_time(day, 18, 0, 0)),
|
||
beijing_time(day + 1, 3, 0, 0)
|
||
);
|
||
assert_eq!(
|
||
duration_until(
|
||
next_forced_refresh_at_or_after(beijing_time(day, 17, 45, 0)),
|
||
beijing_time(day, 17, 45, 0)
|
||
),
|
||
Duration::from_secs(15 * 60)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn formats_progress_durations() {
|
||
assert_eq!(format_duration(Duration::from_millis(7)), "00:00.007");
|
||
assert_eq!(format_duration(Duration::from_millis(65_432)), "01:05.432");
|
||
}
|
||
|
||
#[test]
|
||
fn quiet_up_to_date_suppresses_only_clean_reports() {
|
||
assert!(!should_print_status(OfficialUpdateStatus::UpToDate, true));
|
||
assert!(should_print_status(OfficialUpdateStatus::Downloaded, true));
|
||
assert!(should_print_status(
|
||
OfficialUpdateStatus::WouldDownload,
|
||
true
|
||
));
|
||
assert!(should_print_status(OfficialUpdateStatus::UpToDate, false));
|
||
}
|
||
|
||
#[test]
|
||
fn status_string_is_stable() {
|
||
assert_eq!(OfficialUpdateStatus::UpToDate.as_str(), "up_to_date");
|
||
assert_eq!(
|
||
OfficialUpdateStatus::WouldDownload.as_str(),
|
||
"would_download"
|
||
);
|
||
assert_eq!(OfficialUpdateStatus::Downloaded.as_str(), "downloaded");
|
||
}
|
||
|
||
#[test]
|
||
fn redacts_proxy_credentials_in_surfaced_command() {
|
||
let command = vec![
|
||
"/opt/bat".to_string(),
|
||
"--auto-discover".to_string(),
|
||
"--proxy".to_string(),
|
||
"http://user:secret@127.0.0.1:7890".to_string(),
|
||
"--output".to_string(),
|
||
"/var/lib/bat".to_string(),
|
||
];
|
||
|
||
let redacted = redact_command_proxy_credentials(command);
|
||
|
||
assert_eq!(redacted[3], "http://<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()],
|
||
}
|
||
}
|
||
}
|