Files
BlueArchiveToolkit/infrastructure/src/bin/bat/app.rs
T
2026-08-03 00:43:11 +08:00

7107 lines
268 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use bat_adapters::official::yostar_jp::{PatchPlatform, YostarJpResourceEndpointKind};
use bat_assetbundle::UnitySerializedReplacementValue;
use bat_core::domain::{Resource, ResourceType};
use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository};
use bat_core::{ApiError, ErrorCode};
#[cfg(test)]
use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY;
use bat_infrastructure::{
apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file,
apply_unityfs_text_asset_patch_file, changed_endpoint_urls, diff_extended_snapshot,
gc_orphan_staging, lexical_absolute, open_append_file, read_download_manifest_at,
read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state,
read_parse_cache_at, read_snapshot, read_textunit_index_at, read_version_state,
redact_proxy_url, resolve_curl_proxy, validate_output_root, validate_runtime_state_dir,
write_file_atomic, CurlProxyConfig, CurlProxyMode, OfficialEndpointMarkerRole,
OfficialFailedVersionRecord, OfficialResourceHashVerification, OfficialResourceVerification,
OfficialServerInfoSource, OfficialTextUnitQuery, OfficialTextUnitTaskQuery,
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
OfficialVersionRecord, OfficialVersionState, PatchApplyKind, PatchApplyParams,
PatchApplyReport, ReleaseFlowStatusCode, SqliteResourceRepository,
SqliteTranslationTaskRepository, TranslationTaskStatus, UnityFsFieldPatchParams,
UnityFsPatchReport, UnityFsStringFieldPatchParams, UnityFsTextAssetPatchParams,
LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_VERSIONS_DIR,
LOCALIZED_VERSION_STATE_FILE, MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY,
OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_TEXTUNIT_INDEX_FILE, PRIVATE_FILE_MODE,
};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
#[cfg(unix)]
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc, Condvar, Mutex};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
#[path = "patch_commands.rs"]
mod patch_commands;
#[path = "readonly_query.rs"]
mod readonly_query;
#[path = "task_registry.rs"]
mod task_registry;
#[path = "translation_query.rs"]
mod translation_query;
use patch_commands::{
is_write_patch_command, run_write_patch_command, validate_write_patch_options,
};
#[cfg(test)]
use readonly_query::run_readonly_query_command_with_rpc;
use readonly_query::{run_readonly_query_command, validate_readonly_query_options};
use task_registry::{
run_task_worker, CancelOutcome, DaemonTaskContext, TaskJob, TaskKind, TaskRegistry,
};
#[cfg(test)]
use task_registry::{
PersistedTaskFile, MAX_RETAINED_TASKS, MAX_TASK_LOG_LINES, TASKS_FILE_NAME, TASKS_FILE_VERSION,
};
use translation_query::{
build_translation_handoff_report, build_translation_tasks_report, textunit_query_json,
update_translation_task_status_report,
};
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";
const SHUTDOWN_SIGNAL_POLL_MILLISECONDS: u64 = 100;
/// 后台进程保存代理凭据的专用文件,仅供 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
=====================================================================================
"#;
pub fn main() {
match run() {
Ok(exit_code) if exit_code != 0 => std::process::exit(exit_code),
Ok(_) => {}
Err(error) => {
let error_message = error.to_string();
let exit_code = if is_locked_error(&error_message) {
EXIT_LOCKED
} else {
EXIT_ERROR
};
let payload = ErrorReport {
status: "error",
exit_code,
error: error_message,
next_retry_seconds: None,
};
eprintln!(
"{}",
serde_json::to_string_pretty(&payload)
.unwrap_or_else(|_| "{\"status\":\"error\"}".to_string())
);
std::process::exit(exit_code);
}
}
}
fn run() -> anyhow::Result<i32> {
bootstrap_env_file();
let options = parse_args()?;
if matches!(
options.command,
CliCommand::Run | CliCommand::Refresh | CliCommand::Verify | CliCommand::Repair
) && !options.daemon
&& options.banner
{
print_startup_banner();
}
match options.command {
CliCommand::Run => {
if options.daemon {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
run_daemon_start(options)?;
} else if options.watch {
if !options.daemon_child {
assert_no_live_daemon_output_conflict(&options, "watch")?;
}
run_watch(options)?;
} else {
assert_no_live_daemon_output_conflict(&options, "run")?;
let mut logger = ProgressLogger::new(options.progress);
let report =
OfficialUpdateService::new().run_with_progress(&options.config, |event| {
logger.log(event);
})?;
if should_print_status(report.update_status, options.quiet_up_to_date) {
print_report(options.output_format, &report)?;
}
}
Ok(0)
}
CliCommand::Status => {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
print_daemon_status(&options.state_dir, options.output_format)?;
Ok(0)
}
CliCommand::Stop => {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
stop_daemon(&options.state_dir, options.output_format)?;
Ok(0)
}
CliCommand::Restart => {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
run_daemon_restart(&options, "restart")?;
Ok(0)
}
CliCommand::Reload => {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
run_daemon_restart(&options, "reload")?;
Ok(0)
}
CliCommand::Refresh => {
run_sync_command(&options, "refresh")?;
Ok(0)
}
CliCommand::Verify => {
let healthy = run_verify_command(&options)?;
Ok(if healthy { 0 } else { EXIT_ERROR })
}
CliCommand::Repair => {
run_sync_command(&options, "repair")?;
Ok(0)
}
CliCommand::ParseStatus
| CliCommand::ParseTextUnits
| CliCommand::ParseErrors
| CliCommand::TranslationTasks
| CliCommand::TranslationHandoff
| CliCommand::LocalizedStatus
| CliCommand::ResourceIndex => {
run_readonly_query_command(&options)?;
Ok(0)
}
CliCommand::PatchApply
| CliCommand::UnityFsPatchTextAsset
| CliCommand::UnityFsPatchStringField
| CliCommand::UnityFsPatchField => {
run_write_patch_command(&options)?;
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,
query_offset: usize,
query_limit: usize,
query_task_id: Option<String>,
query_resource_type: Option<ResourceType>,
query_hash: Option<String>,
query_path_pattern: Option<String>,
query_official_release_id: Option<String>,
query_platform: Option<String>,
query_destination: Option<String>,
query_bundle_path: Option<String>,
query_archive_entry: Option<String>,
query_task_status: Option<String>,
query_worker_status: Option<String>,
query_parse_status: Option<String>,
query_path_id: Option<i64>,
query_class_id: Option<i32>,
query_field_path: Option<String>,
query_format: Option<String>,
query_has_reason: Option<bool>,
query_has_failure_reason: Option<bool>,
query_option_explicit: bool,
patch_kind: Option<PatchApplyKind>,
patch_source_path: Option<PathBuf>,
patch_patch_path: Option<PathBuf>,
patch_target_path: Option<PathBuf>,
unityfs_bundle_path: Option<PathBuf>,
unityfs_serialized_file_path: Option<String>,
unityfs_path_id: Option<i64>,
unityfs_field_path: Option<String>,
unityfs_replacement_path: Option<PathBuf>,
unityfs_replacement_text: Option<String>,
unityfs_expected_name: Option<String>,
unityfs_expected_value: Option<String>,
unityfs_replacement_value: Option<UnitySerializedReplacementValue>,
unityfs_expected_semantic_value: Option<UnitySerializedReplacementValue>,
write_patch_option_explicit: bool,
/// 环境变量(含 .env)应用后、命令行解析前的配置快照。
/// 工具/代理"是否命令行显式传入"的判断以它为基线。
env_baseline_config: OfficialUpdateConfig,
}
impl Default for CliOptions {
fn default() -> Self {
Self {
config: OfficialUpdateConfig::default(),
command: CliCommand::Run,
output_format: OutputFormat::Human,
watch: false,
daemon: false,
daemon_child: false,
state_dir: PathBuf::from(DEFAULT_DAEMON_STATE_DIR),
output_explicit: false,
sync_option_explicit: false,
proxy_option_explicit: false,
interval: Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS),
error_retry_interval: Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS),
quiet_up_to_date: false,
quiet_up_to_date_explicit: false,
progress: true,
banner: true,
tail_lines: 200,
query_offset: 0,
query_limit: 100,
query_task_id: None,
query_resource_type: None,
query_hash: None,
query_path_pattern: None,
query_official_release_id: None,
query_platform: None,
query_destination: None,
query_bundle_path: None,
query_archive_entry: None,
query_task_status: None,
query_worker_status: None,
query_parse_status: None,
query_path_id: None,
query_class_id: None,
query_field_path: None,
query_format: None,
query_has_reason: None,
query_has_failure_reason: None,
query_option_explicit: false,
patch_kind: None,
patch_source_path: None,
patch_patch_path: None,
patch_target_path: None,
unityfs_bundle_path: None,
unityfs_serialized_file_path: None,
unityfs_path_id: None,
unityfs_field_path: None,
unityfs_replacement_path: None,
unityfs_replacement_text: None,
unityfs_expected_name: None,
unityfs_expected_value: None,
unityfs_replacement_value: None,
unityfs_expected_semantic_value: None,
write_patch_option_explicit: false,
env_baseline_config: OfficialUpdateConfig::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OutputFormat {
Human,
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CliCommand {
Run,
Status,
Stop,
Restart,
Reload,
Refresh,
Verify,
Repair,
ParseStatus,
ParseTextUnits,
ParseErrors,
TranslationTasks,
TranslationHandoff,
LocalizedStatus,
ResourceIndex,
PatchApply,
UnityFsPatchTextAsset,
UnityFsPatchStringField,
UnityFsPatchField,
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)?;
}
install_shutdown_signal_handlers()?;
clear_shutdown_signal_request();
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));
}
// 前台 watch 也使用控制状态,使信号停止和 daemon/RPC 停止共享同一条退出路径。
let daemon_control = Some(new_daemon_control());
// 进程内同步锁:watch 循环与任务 worker 在跑同步前都获取它,互相等待而非撞文件锁失败。
let sync_lock = Arc::new(Mutex::new(()));
let (_task_worker, _task_context, _rpc_server) = if options.daemon_child {
let control = daemon_control
.as_ref()
.expect("daemon control must exist for daemon child");
// 任务历史持久化在 state dir(此前已通过 validate_runtime_state_dir 校验)。
let (registry, restore_summary) = TaskRegistry::with_persistence(&daemon_state_dir);
logger.log_text("daemon", format!("任务历史:{restore_summary}"));
let (task_tx, task_rx) = mpsc::channel::<TaskJob>();
let worker = {
let registry = registry.clone();
let sync_lock = Arc::clone(&sync_lock);
let control = Arc::clone(control);
thread::Builder::new()
.name("bat-daemon-task-worker".to_string())
.spawn(move || run_task_worker(task_rx, registry, sync_lock, control))?
};
let context = DaemonTaskContext {
registry,
queue: task_tx,
base_config: options.config.clone(),
restart_controller: spawn_daemon_restart_controller,
};
let server =
start_daemon_rpc_server(&daemon_state_dir, Arc::clone(control), context.clone())?;
(Some(worker), Some(context), Some(server))
} else {
(None, None, None)
};
let mut next_forced_refresh_at = next_forced_refresh_at_or_after(SystemTime::now());
let mut pending_scheduled_force = false;
let mut pending_rpc_force = false;
logger.log_text(
"watch",
format!(
"每日强制刷新时间:{DAILY_FORCED_REFRESH_LABEL};距离下一次强制刷新还有 {}",
format_duration(duration_until(next_forced_refresh_at, SystemTime::now()))
),
);
loop {
if watch_stop_requested(daemon_control.as_ref()) {
logger.log_text("daemon", "收到停止请求,watch 循环准备退出");
break;
}
let now = SystemTime::now();
if now >= next_forced_refresh_at {
pending_scheduled_force = true;
logger.log_text(
"watch",
format!("已触发固定时间强制刷新:{DAILY_FORCED_REFRESH_LABEL}"),
);
next_forced_refresh_at = next_forced_refresh_after(now);
}
let mut iteration_config = options.config.clone();
if pending_scheduled_force {
iteration_config.force = true;
}
let rpc_force_this_round = pending_rpc_force;
if rpc_force_this_round {
iteration_config.force = true;
pending_rpc_force = false;
}
let mut sleep_for = options.interval;
logger.log_text(
"watch",
if pending_scheduled_force && rpc_force_this_round {
"开始执行 watch 轮次:本轮为固定时间和 RPC 触发的强制刷新"
} else if pending_scheduled_force {
"开始执行 watch 轮次:本轮为固定时间强制刷新"
} else if rpc_force_this_round {
"开始执行 watch 轮次:本轮为 RPC 触发的强制刷新"
} else {
"开始执行 watch 轮次"
},
);
record_daemon_status(
options.daemon_child,
&daemon_state_dir,
&mut logger,
DaemonStatusUpdate {
state: "running",
last_update_status: None,
last_error: None,
next_retry_seconds: None,
last_success_unix_seconds: None,
next_check_unix_seconds: None,
pending_scheduled_force,
next_forced_refresh_at,
},
);
// 只在实际执行同步的这段持有 sync_lock,与任务 worker 互斥;空闲睡眠时不持锁。
let run_result = {
let _sync_guard = sync_lock
.lock()
.unwrap_or_else(|poison| poison.into_inner());
service.run_with_progress_and_cancellation(
&iteration_config,
|event| {
record_daemon_progress(
options.daemon_child,
&daemon_state_dir,
&mut logger,
&event,
);
logger.log(event);
},
|| watch_stop_requested(daemon_control.as_ref()),
)
};
match run_result {
Ok(report) => {
if pending_scheduled_force {
pending_scheduled_force = false;
}
if should_print_status(report.update_status, options.quiet_up_to_date) {
print_report(options.output_format, &report)?;
}
let waiting_for_official_resources =
report.update_status == OfficialUpdateStatus::WaitingForOfficialResources;
if waiting_for_official_resources {
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: if waiting_for_official_resources {
"waiting"
} else {
"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: (!waiting_for_official_resources)
.then(unix_seconds_now),
next_check_unix_seconds: Some(unix_seconds_after(sleep_for)),
pending_scheduled_force,
next_forced_refresh_at,
},
);
if waiting_for_official_resources {
logger.log_text(
"watch",
format!(
"本轮等待官方资源端开放:状态={};将在 {} 后重试;距离下一次固定强制刷新还有 {}",
report.update_status.as_str(),
format_duration(sleep_for),
format_duration(duration_until(next_forced_refresh_at, SystemTime::now()))
),
);
} else {
logger.log_text(
"watch",
format!(
"本轮完成:状态={};下次检查将在 {} 后执行;距离下一次固定强制刷新还有 {}",
report.update_status.as_str(),
format_duration(sleep_for),
format_duration(duration_until(next_forced_refresh_at, SystemTime::now()))
),
);
}
}
Err(error) => {
sleep_for = options.error_retry_interval;
sleep_for =
sleep_for.min(duration_until(next_forced_refresh_at, SystemTime::now()));
record_daemon_status(
options.daemon_child,
&daemon_state_dir,
&mut logger,
DaemonStatusUpdate {
state: "error",
last_update_status: None,
last_error: Some(error.to_string()),
next_retry_seconds: Some(sleep_for.as_secs()),
last_success_unix_seconds: None,
next_check_unix_seconds: Some(unix_seconds_after(sleep_for)),
pending_scheduled_force,
next_forced_refresh_at,
},
);
logger.log_text(
"watch",
format!(
"本轮失败;将在 {} 后重试:{}",
format_duration(sleep_for),
error
),
);
let payload = ErrorReport {
status: "error",
exit_code: EXIT_ERROR,
error: error.to_string(),
next_retry_seconds: Some(sleep_for.as_secs()),
};
eprintln!("{}", serde_json::to_string_pretty(&payload)?);
if watch_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,
#[serde(default)]
localized_output_root: Option<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)]
status_code: 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>,
#[serde(default)]
verification: Option<OfficialResourceVerification>,
#[serde(default)]
official_hash: Option<OfficialResourceHashVerification>,
}
#[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")]
controller_pid: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
force: Option<bool>,
}
// 规范方法名采用国际惯例的 `<namespace>.<action>`。`bat.*` 保留为向后兼容别名。
const RPC_METHOD_STATUS: &str = "daemon.status";
const RPC_METHOD_STOP: &str = "daemon.stop";
const RPC_METHOD_RESTART: &str = "daemon.restart";
const RPC_METHOD_RELOAD: &str = "daemon.reload";
const RPC_METHOD_REFRESH: &str = "daemon.refresh";
const RPC_METHOD_LOGS: &str = "daemon.logs";
const RPC_METHOD_DOCTOR: &str = "daemon.doctor";
const RPC_METHOD_CLEAN_STABLE: &str = "daemon.clean-stable";
const RPC_METHOD_RESOURCE_STATE: &str = "resource.state";
const RPC_METHOD_RESOURCE_SYNC: &str = "resource.sync";
const RPC_METHOD_RESOURCE_VERIFY: &str = "resource.verify";
const RPC_METHOD_RESOURCE_REPAIR: &str = "resource.repair";
const RPC_METHOD_RESOURCE_MANIFEST: &str = "resource.manifest";
const RPC_METHOD_RESOURCE_INDEX: &str = "resource.index";
const RPC_METHOD_RESOURCE_LIST: &str = "resource.list";
const RPC_METHOD_PARSE_STATUS: &str = "parse.status";
const RPC_METHOD_PARSE_TEXT_UNITS: &str = "parse.text_units";
const RPC_METHOD_PARSE_ERRORS: &str = "parse.errors";
const RPC_METHOD_TRANSLATION_TASKS: &str = "translation.tasks";
const RPC_METHOD_TRANSLATION_HANDOFF: &str = "translation.handoff";
const RPC_METHOD_TRANSLATION_TASK_UPDATE: &str = "translation.task.update";
const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status";
const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status";
const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions";
const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff";
const RPC_METHOD_CATALOG_REFRESH: &str = "catalog.refresh";
const RPC_METHOD_TASK_STATUS: &str = "task.status";
const RPC_METHOD_TASK_LIST: &str = "task.list";
const RPC_METHOD_TASK_CANCEL: &str = "task.cancel";
const RPC_METHOD_TASK_LOGS: &str = "task.logs";
const RPC_METHOD_PATCH_APPLY: &str = "patch.apply";
const RPC_METHOD_UNITYFS_PATCH_TEXT_ASSET: &str = "unityfs.patch_text_asset";
const RPC_METHOD_UNITYFS_PATCH_STRING_FIELD: &str = "unityfs.patch_string_field";
const RPC_METHOD_UNITYFS_PATCH_FIELD: &str = "unityfs.patch_field";
/// 保留的已完成任务上限(内存态,超出后裁剪最旧的已结束任务)。
fn canonical_rpc_method(method: &str) -> &str {
match method {
"bat.status" => RPC_METHOD_STATUS,
"bat.stop" => RPC_METHOD_STOP,
"bat.restart" => RPC_METHOD_RESTART,
"bat.reload" => RPC_METHOD_RELOAD,
"bat.refresh" => RPC_METHOD_REFRESH,
"bat.logs" => RPC_METHOD_LOGS,
"bat.doctor" => RPC_METHOD_DOCTOR,
"bat.clean-stable" => RPC_METHOD_CLEAN_STABLE,
RPC_METHOD_RESOURCE_LIST => RPC_METHOD_RESOURCE_MANIFEST,
other => other,
}
}
/// 判断方法是否属于已规划但尚未实现的命名空间/动作(返回 not_implemented 而非 unknown)。
fn is_pending_rpc_method(method: &str) -> bool {
// task.create:任务统一由 resource.sync / resource.verify / resource.repair / catalog.refresh
// 等语义方法创建,通用创建接口暂不开放。
// daemon.clean-stableCLI 侧按进程生命周期处理;
// live RPC 内不做在线清理。
// patch.* / unityfs.*:文件级写入入口已开放;发布级 patch 构建、复杂
// UnityFS 语义编辑和 inspect 等子命令仍未开放。
matches!(method, "task.create" | RPC_METHOD_CLEAN_STABLE)
|| (method.starts_with("patch.") && method != RPC_METHOD_PATCH_APPLY)
|| (method.starts_with("unityfs.")
&& method != RPC_METHOD_UNITYFS_PATCH_TEXT_ASSET
&& method != RPC_METHOD_UNITYFS_PATCH_STRING_FIELD
&& method != RPC_METHOD_UNITYFS_PATCH_FIELD)
}
/// RPC 应用层统一 envelope,装入 JSON-RPC 2.0 的 `result`。
///
/// 所有响应都带 `ok`/`status`/`data`/`error`/`request_id`;传输层错误(请求解析失败)
/// 仍走 JSON-RPC 顶层 `error`。
#[derive(Debug, Serialize)]
struct RpcEnvelope {
ok: bool,
status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<ApiError>,
request_id: String,
}
/// 生成进程内唯一的 request_id`req-<pid>-<seq>`)。
fn next_request_id() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(1);
format!(
"req-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
)
}
fn rpc_envelope_ok(
request_id: String,
status: &'static str,
data: serde_json::Value,
) -> RpcEnvelope {
RpcEnvelope {
ok: true,
status,
data: Some(data),
error: None,
request_id,
}
}
fn rpc_envelope_error(request_id: String, error: ApiError) -> RpcEnvelope {
RpcEnvelope {
ok: false,
status: "error",
data: None,
error: Some(error),
request_id,
}
}
/// 把内部 `anyhow::Result<Value>` 转成 envelope;成功为 ok,失败归为内部错误码。
fn rpc_envelope_from_result(
request_id: String,
location: &'static str,
result: anyhow::Result<serde_json::Value>,
) -> RpcEnvelope {
match result {
Ok(data) => rpc_envelope_ok(request_id, "ok", data),
Err(error) => rpc_envelope_error(
request_id,
ApiError::new(ErrorCode::INTERNAL, location, error.to_string()),
),
}
}
fn record_daemon_status(
enabled: bool,
state_dir: &Path,
logger: &mut ProgressLogger,
update: DaemonStatusUpdate<'_>,
) {
if !enabled {
return;
}
if let Err(error) = update_daemon_status(state_dir, update) {
logger.log_text("daemon", format!("更新后台状态失败:{error}"));
}
}
fn record_daemon_progress(
enabled: bool,
state_dir: &Path,
logger: &mut ProgressLogger,
event: &OfficialUpdateProgress,
) {
if !enabled {
return;
}
if let Err(error) = update_daemon_progress(state_dir, event) {
logger.log_text("daemon", format!("更新后台进度失败:{error}"));
}
}
#[derive(Debug)]
struct DaemonRpcServer {
socket_path: PathBuf,
}
impl Drop for DaemonRpcServer {
fn drop(&mut self) {
let _ = fs::remove_file(&self.socket_path);
}
}
#[derive(Debug)]
struct DaemonControlLock {
path: PathBuf,
pid: u32,
}
impl DaemonControlLock {
fn acquire(state_dir: &Path) -> anyhow::Result<Self> {
validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?;
fs::create_dir_all(state_dir)?;
let path = daemon_control_lock_path(state_dir);
let pid = std::process::id();
for attempt in 0..=1 {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(PRIVATE_FILE_MODE);
match options.open(&path) {
Ok(mut file) => {
file.write_all(pid.to_string().as_bytes())?;
return Ok(Self { path, pid });
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
if attempt == 0 && remove_recoverable_pid_lock(&path)? {
continue;
}
return Err(anyhow::anyhow!(
"后台控制命令已被锁定 (locked):{};{};如确认没有 bat 控制命令正在运行,可执行 clean-stable 清理",
path.display(),
describe_pid_lock_owner(&path)?
));
}
Err(error) => {
return Err(anyhow::anyhow!(
"获取后台控制锁失败 {}{error}",
path.display()
));
}
}
}
Err(anyhow::anyhow!("获取后台控制锁失败 {}", path.display()))
}
}
impl Drop for DaemonControlLock {
fn drop(&mut self) {
let expected = self.pid.to_string();
if fs::symlink_metadata(&self.path)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
{
return;
}
if fs::read_to_string(&self.path)
.map(|contents| contents.trim() == expected)
.unwrap_or(false)
{
let _ = fs::remove_file(&self.path);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PidLockState {
Missing,
Active(u32),
StalePid(u32),
Corrupt,
}
fn classify_pid_lock_file(path: &Path) -> anyhow::Result<PidLockState> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(PidLockState::Missing);
}
Err(error) => return Err(error.into()),
};
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Ok(PidLockState::Corrupt);
}
let contents = fs::read_to_string(path)?;
let Some(pid) = parse_pid_value(&contents) else {
return Ok(PidLockState::Corrupt);
};
if process_exists(pid) {
Ok(PidLockState::Active(pid))
} else {
Ok(PidLockState::StalePid(pid))
}
}
fn remove_recoverable_pid_lock(path: &Path) -> anyhow::Result<bool> {
match classify_pid_lock_file(path)? {
PidLockState::Missing => Ok(false),
PidLockState::Active(_) => Ok(false),
PidLockState::StalePid(_) | PidLockState::Corrupt => {
fs::remove_file(path)?;
Ok(true)
}
}
}
fn describe_pid_lock_owner(path: &Path) -> anyhow::Result<String> {
Ok(match classify_pid_lock_file(path)? {
PidLockState::Missing => "锁文件已不存在".to_string(),
PidLockState::Active(pid) => format!("owner_pid={pid} 仍在运行"),
PidLockState::StalePid(pid) => format!("owner_pid={pid} 已失效"),
PidLockState::Corrupt => "锁文件内容不是有效 PID".to_string(),
})
}
fn parse_pid_value(contents: &str) -> Option<u32> {
contents.trim().parse::<u32>().ok().filter(|pid| *pid > 0)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DaemonResourceConflict {
pid: Option<u32>,
daemon_root: Option<PathBuf>,
target_root: PathBuf,
}
fn assert_no_live_daemon_output_conflict(
options: &CliOptions,
command_name: &str,
) -> anyhow::Result<()> {
if options.daemon || options.daemon_child || options.config.dry_run {
return Ok(());
}
let Some(conflict) =
live_daemon_resource_conflict(&options.state_dir, &options.config.output_root)?
else {
return Ok(());
};
let daemon_root = conflict
.daemon_root
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "未知".to_string());
let pid = conflict
.pid
.map(|pid| pid.to_string())
.unwrap_or_else(|| "未知".to_string());
Err(anyhow::anyhow!(
"后台同步进程正在管理同一资源目录 (locked)command={command_name} pid={pid} daemon_output={} target_output={};请通过后台 refresh/reload 控制刷新,或先执行 stop 再手动写入资源",
daemon_root,
conflict.target_root.display()
))
}
fn live_daemon_resource_conflict(
state_dir: &Path,
output_root: &Path,
) -> anyhow::Result<Option<DaemonResourceConflict>> {
let pid_path = daemon_pid_path(state_dir);
let status_path = daemon_status_path(state_dir);
let pid_from_file = read_pid_file(&pid_path)?;
let rpc_available = daemon_rpc_available(state_dir);
let pid_file_live = pid_from_file.map(process_exists).unwrap_or(false);
let status_file = match read_daemon_status_file(&status_path) {
Ok(status_file) => status_file,
Err(error) if pid_file_live || rpc_available => {
return Err(anyhow::anyhow!(
"后台同步进程可能仍在运行,但状态文件不可解析 (locked){}{error};请先执行 status/stop 或 clean-stable 恢复状态目录",
status_path.display()
));
}
Err(_) => return Ok(None),
};
let status_pid = status_file.as_ref().map(|status| status.pid);
let pid = pid_from_file.or(status_pid);
let live = pid_file_live || status_pid.map(process_exists).unwrap_or(false) || rpc_available;
if !live {
return Ok(None);
}
let target_root = normalized_abs_path(output_root)?;
let Some(daemon_root) = status_file
.as_ref()
.map(|status| status.resource_output_root.clone())
else {
return Ok(Some(DaemonResourceConflict {
pid,
daemon_root: None,
target_root,
}));
};
let normalized_daemon_root = normalized_abs_path(&daemon_root)?;
if normalized_daemon_root == target_root {
Ok(Some(DaemonResourceConflict {
pid,
daemon_root: Some(normalized_daemon_root),
target_root,
}))
} else {
Ok(None)
}
}
fn normalized_abs_path(path: &Path) -> anyhow::Result<PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
env::current_dir()?.join(path)
};
Ok(fs::canonicalize(&absolute).unwrap_or_else(|_| lexically_normalize_path(&absolute)))
}
fn lexically_normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
if !normalized.pop() {
normalized.push(component.as_os_str());
}
}
_ => normalized.push(component.as_os_str()),
}
}
if normalized.as_os_str().is_empty() {
PathBuf::from(".")
} else {
normalized
}
}
#[cfg(unix)]
static SHUTDOWN_SIGNAL_REQUESTED: AtomicBool = AtomicBool::new(false);
#[cfg(unix)]
extern "C" fn handle_shutdown_signal(_signal: libc::c_int) {
// Signal handlers may only touch lock-free process state; the watch loop
// performs logging, cancellation, and cleanup after observing this flag.
SHUTDOWN_SIGNAL_REQUESTED.store(true, Ordering::SeqCst);
}
#[cfg(unix)]
fn install_shutdown_signal_handlers() -> anyhow::Result<()> {
unsafe {
if libc::signal(
libc::SIGINT,
handle_shutdown_signal as *const () as libc::sighandler_t,
) == libc::SIG_ERR
{
return Err(anyhow::anyhow!("注册 SIGINT 优雅退出处理器失败"));
}
if libc::signal(
libc::SIGTERM,
handle_shutdown_signal as *const () as libc::sighandler_t,
) == libc::SIG_ERR
{
return Err(anyhow::anyhow!("注册 SIGTERM 优雅退出处理器失败"));
}
}
Ok(())
}
#[cfg(not(unix))]
fn install_shutdown_signal_handlers() -> anyhow::Result<()> {
Ok(())
}
#[cfg(unix)]
fn clear_shutdown_signal_request() {
SHUTDOWN_SIGNAL_REQUESTED.store(false, Ordering::SeqCst);
}
#[cfg(not(unix))]
fn clear_shutdown_signal_request() {}
#[cfg(unix)]
fn shutdown_signal_requested() -> bool {
SHUTDOWN_SIGNAL_REQUESTED.load(Ordering::SeqCst)
}
#[cfg(not(unix))]
fn shutdown_signal_requested() -> bool {
false
}
fn new_daemon_control() -> DaemonControl {
Arc::new((Mutex::new(DaemonControlState::default()), Condvar::new()))
}
fn watch_stop_requested(control: Option<&DaemonControl>) -> bool {
shutdown_signal_requested() || daemon_control_stop_requested(control)
}
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 started_at = Instant::now();
let Some(control) = control else {
loop {
if shutdown_signal_requested() {
return DaemonWake::Stop;
}
let elapsed = started_at.elapsed();
if elapsed >= timeout {
return DaemonWake::Timeout;
}
let remaining = timeout.saturating_sub(elapsed);
thread::sleep(remaining.min(Duration::from_millis(SHUTDOWN_SIGNAL_POLL_MILLISECONDS)));
}
};
let (lock, cvar) = &**control;
let Ok(mut state) = lock.lock() else {
return DaemonWake::Stop;
};
loop {
if shutdown_signal_requested() {
return DaemonWake::Stop;
}
if let Some(wake) = take_daemon_wake(&mut state) {
return wake;
}
let elapsed = started_at.elapsed();
if elapsed >= timeout {
return DaemonWake::Timeout;
}
let remaining = timeout.saturating_sub(elapsed);
let wait_for = remaining.min(Duration::from_millis(SHUTDOWN_SIGNAL_POLL_MILLISECONDS));
let Ok((next_state, _timeout)) = cvar.wait_timeout(state, wait_for) else {
return DaemonWake::Stop;
};
state = next_state;
}
}
fn take_daemon_wake(state: &mut DaemonControlState) -> Option<DaemonWake> {
if state.stop_requested {
return Some(DaemonWake::Stop);
}
if state.reload_requested {
state.reload_requested = false;
state.refresh_requested = false;
state.force_refresh_requested = false;
return Some(DaemonWake::Reload);
}
if state.refresh_requested {
state.refresh_requested = false;
let force = state.force_refresh_requested;
state.force_refresh_requested = false;
return Some(DaemonWake::Refresh { force });
}
None
}
#[cfg(unix)]
fn start_daemon_rpc_server(
state_dir: &Path,
control: DaemonControl,
tasks: DaemonTaskContext,
) -> anyhow::Result<DaemonRpcServer> {
validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?;
fs::create_dir_all(state_dir)?;
let socket_path = daemon_socket_path(state_dir);
if daemon_socket_path_exists(&socket_path)? {
ensure_daemon_socket_not_symlink(&socket_path)?;
match UnixStream::connect(&socket_path) {
Ok(_) => {
return Err(anyhow::anyhow!(
"后台 RPC socket 已被占用:{}",
socket_path.display()
));
}
Err(_) => {
let _ = fs::remove_file(&socket_path);
}
}
}
let listener = UnixListener::bind(&socket_path)?;
let server_state_dir = state_dir.to_path_buf();
thread::Builder::new()
.name("bat-daemon-rpc".to_string())
.spawn(move || {
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let state_dir = server_state_dir.clone();
let control = Arc::clone(&control);
let tasks = tasks.clone();
let _ = thread::Builder::new()
.name("bat-daemon-rpc-client".to_string())
.spawn(move || {
handle_daemon_rpc_client(stream, state_dir, control, tasks)
});
}
Err(error) => {
eprintln!("[daemon] RPC socket accept 失败:{error}");
break;
}
}
}
})?;
Ok(DaemonRpcServer { socket_path })
}
#[cfg(not(unix))]
fn start_daemon_rpc_server(
_state_dir: &Path,
_control: DaemonControl,
_tasks: DaemonTaskContext,
) -> anyhow::Result<DaemonRpcServer> {
Err(anyhow::anyhow!("daemon RPC 目前只支持 Unix/Linux 平台"))
}
#[cfg(unix)]
fn handle_daemon_rpc_client(
mut stream: UnixStream,
state_dir: PathBuf,
control: DaemonControl,
tasks: DaemonTaskContext,
) {
let Ok(reader_stream) = stream.try_clone() else {
return;
};
let mut reader = BufReader::new(reader_stream);
let mut line = String::new();
loop {
line.clear();
let bytes_read = match reader.read_line(&mut line) {
Ok(bytes_read) => bytes_read,
Err(error) => {
eprintln!("[daemon] RPC socket 读取失败:{error}");
return;
}
};
if bytes_read == 0 {
return;
}
if line.trim().is_empty() {
continue;
}
let mut notify_stop_after_response = false;
let response = match serde_json::from_str::<JsonRpcRequest>(&line) {
Ok(request) => {
notify_stop_after_response = matches!(
canonical_rpc_method(&request.method),
RPC_METHOD_STOP | RPC_METHOD_RESTART
);
handle_daemon_rpc_request(request, &state_dir, &control, &tasks)
}
Err(error) => json_rpc_error(None, -32700, format!("JSON-RPC 请求解析失败:{error}")),
};
if let Err(error) = write_json_rpc_response(&mut stream, &response) {
if notify_stop_after_response {
daemon_control_notify_all(&control);
}
eprintln!("[daemon] RPC socket 写入失败:{error}");
return;
}
if notify_stop_after_response {
daemon_control_notify_all(&control);
}
}
}
fn handle_daemon_rpc_request(
request: JsonRpcRequest,
state_dir: &Path,
control: &DaemonControl,
tasks: &DaemonTaskContext,
) -> JsonRpcResponse {
let id = request.id.clone();
let request_id = next_request_id();
let envelope = dispatch_rpc_method(&request, state_dir, control, tasks, request_id);
match serde_json::to_value(&envelope) {
Ok(value) => json_rpc_result(id, value),
Err(error) => json_rpc_error(id, -32603, error.to_string()),
}
}
/// 命名空间分发:把请求路由到对应 handler,统一返回应用层 envelope。
fn dispatch_rpc_method(
request: &JsonRpcRequest,
state_dir: &Path,
control: &DaemonControl,
tasks: &DaemonTaskContext,
request_id: String,
) -> RpcEnvelope {
match canonical_rpc_method(&request.method) {
RPC_METHOD_STATUS => rpc_envelope_from_result(
request_id,
"daemon.status",
build_daemon_status_report(state_dir)
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
),
RPC_METHOD_LOGS => {
let tail = match rpc_tail_param(request.params.as_ref(), 200) {
Ok(tail) => tail,
Err(error) => {
return rpc_envelope_error(
request_id,
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
"daemon.logs",
error.to_string(),
),
)
}
};
rpc_envelope_from_result(
request_id,
"daemon.logs",
build_logs_report(state_dir, tail)
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
)
}
RPC_METHOD_DOCTOR => rpc_envelope_from_result(
request_id,
"daemon.doctor",
build_doctor_report(state_dir, &tasks.base_config)
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
),
RPC_METHOD_STOP => {
daemon_control_mark_stop_requested(control);
let _ = update_daemon_state_only(state_dir, "stopping");
rpc_envelope_ok(
request_id,
"accepted",
rpc_ack_value("stop", "后台停止请求已发送", state_dir, None),
)
}
RPC_METHOD_RESTART => match (tasks.restart_controller)(state_dir) {
Ok(controller_pid) => {
daemon_control_mark_stop_requested(control);
let _ = update_daemon_state_only(state_dir, "restarting");
rpc_envelope_ok(
request_id,
"accepted",
rpc_restart_ack_value(
"restart",
"后台重启控制进程已启动;当前 daemon 会在响应后停止并由 Rust 生命周期入口重启",
state_dir,
controller_pid,
),
)
}
Err(error) => rpc_envelope_error(
request_id,
ApiError::new(
ErrorCode::INTERNAL,
"daemon.restart",
format!("启动后台重启控制进程失败:{error}"),
),
),
},
RPC_METHOD_RELOAD => {
daemon_control_request_reload(control);
rpc_envelope_ok(
request_id,
"accepted",
rpc_ack_value(
"reload",
"后台重新加载请求已发送;空闲时会立即唤醒,忙碌时会在当前轮结束后重新执行自动发现和强制刷新",
state_dir,
Some(true),
),
)
}
RPC_METHOD_REFRESH => {
let force = rpc_bool_param(request.params.as_ref(), "force").unwrap_or(false);
daemon_control_request_refresh(control, force);
let message = if force {
"后台强制刷新请求已发送;空闲时会立即唤醒,忙碌时会在当前轮结束后执行"
} else {
"后台刷新检查请求已发送;空闲时会立即唤醒,忙碌时会在当前轮结束后执行"
};
rpc_envelope_ok(
request_id,
"accepted",
rpc_ack_value("refresh", message, state_dir, Some(force)),
)
}
RPC_METHOD_RESOURCE_STATE => rpc_envelope_from_result(
request_id,
"resource.state",
build_resource_state_report(state_dir),
),
RPC_METHOD_RESOURCE_SYNC => {
let force = rpc_bool_param(request.params.as_ref(), "force").unwrap_or(false);
enqueue_task_envelope(tasks, TaskKind::Sync, force, request_id)
}
RPC_METHOD_RESOURCE_VERIFY => {
enqueue_task_envelope(tasks, TaskKind::Verify, false, request_id)
}
RPC_METHOD_RESOURCE_REPAIR => {
enqueue_task_envelope(tasks, TaskKind::Repair, false, request_id)
}
RPC_METHOD_RESOURCE_MANIFEST => {
let (offset, limit) = match rpc_page_params(request.params.as_ref()) {
Ok(page) => page,
Err(error) => {
return rpc_envelope_error(
request_id,
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
"resource.manifest",
error.to_string(),
),
)
}
};
rpc_envelope_from_result(
request_id,
"resource.manifest",
build_resource_manifest_report(state_dir, offset, limit),
)
}
RPC_METHOD_RESOURCE_INDEX => {
let (query, offset, limit) = match rpc_resource_index_params(request.params.as_ref()) {
Ok(params) => params,
Err(error) => {
return rpc_envelope_error(
request_id,
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
"resource.index",
error.to_string(),
),
)
}
};
rpc_envelope_from_result(
request_id,
"resource.index",
build_resource_index_report(state_dir, &tasks.base_config, query, offset, limit),
)
}
RPC_METHOD_PARSE_STATUS => rpc_envelope_from_result(
request_id,
"parse.status",
build_parse_status_report(state_dir),
),
RPC_METHOD_PARSE_TEXT_UNITS => {
let (query, offset, limit) = match rpc_textunit_query_params(request.params.as_ref()) {
Ok(params) => params,
Err(error) => {
return rpc_envelope_error(
request_id,
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
"parse.text_units",
error.to_string(),
),
)
}
};
rpc_envelope_from_result(
request_id,
"parse.text_units",
build_parse_text_units_report(state_dir, query, offset, limit),
)
}
RPC_METHOD_PARSE_ERRORS => {
let (query, offset, limit) = match rpc_textunit_query_params(request.params.as_ref()) {
Ok(params) => params,
Err(error) => {
return rpc_envelope_error(
request_id,
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
"parse.errors",
error.to_string(),
),
)
}
};
rpc_envelope_from_result(
request_id,
"parse.errors",
build_parse_errors_report(state_dir, query, offset, limit),
)
}
RPC_METHOD_TRANSLATION_TASKS => {
let (query, offset, limit) =
match rpc_translation_task_query_params(request.params.as_ref()) {
Ok(params) => params,
Err(error) => {
return rpc_envelope_error(
request_id,
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
"translation.tasks",
error.to_string(),
),
)
}
};
rpc_envelope_from_result(
request_id,
"translation.tasks",
build_translation_tasks_report(state_dir, query, offset, limit),
)
}
RPC_METHOD_TRANSLATION_HANDOFF => rpc_envelope_from_result(
request_id,
"translation.handoff",
build_translation_handoff_report(state_dir),
),
RPC_METHOD_TRANSLATION_TASK_UPDATE => rpc_envelope_from_result(
request_id,
"translation.task.update",
update_translation_task_status_report(state_dir, request.params.as_ref()),
),
RPC_METHOD_LOCALIZED_STATUS => rpc_envelope_from_result(
request_id,
"localized.status",
build_localized_status_report(state_dir, &tasks.base_config),
),
RPC_METHOD_CATALOG_STATUS => rpc_envelope_from_result(
request_id,
"catalog.status",
build_catalog_status_report(state_dir),
),
RPC_METHOD_CATALOG_VERSIONS => rpc_envelope_from_result(
request_id,
"catalog.versions",
build_catalog_versions_report(state_dir),
),
RPC_METHOD_CATALOG_DIFF => rpc_envelope_from_result(
request_id,
"catalog.diff",
build_catalog_diff_report(state_dir),
),
RPC_METHOD_CATALOG_REFRESH => {
let force = rpc_bool_param(request.params.as_ref(), "force").unwrap_or(false);
enqueue_task_envelope(tasks, TaskKind::Refresh, force, request_id)
}
RPC_METHOD_TASK_STATUS => {
let task_id = rpc_task_id_param(request);
match tasks.registry.get(task_id) {
Some(record) => rpc_envelope_from_result(
request_id,
"task.status",
serde_json::to_value(record).map_err(anyhow::Error::from),
),
None => rpc_envelope_error(
request_id,
ApiError::new(
ErrorCode::TASK_NOT_FOUND,
"task.status",
format!("任务不存在:{task_id}"),
),
),
}
}
RPC_METHOD_TASK_LIST => rpc_envelope_from_result(
request_id,
"task.list",
serde_json::to_value(serde_json::json!({ "tasks": tasks.registry.list() }))
.map_err(anyhow::Error::from),
),
RPC_METHOD_TASK_CANCEL => {
let task_id = rpc_task_id_param(request);
match tasks.registry.request_cancel(task_id) {
CancelOutcome::Requested => rpc_envelope_ok(
request_id,
"accepted",
serde_json::json!({ "task_id": task_id, "cancel_requested": true }),
),
CancelOutcome::AlreadyFinished => rpc_envelope_ok(
request_id,
"ok",
serde_json::json!({ "task_id": task_id, "cancel_requested": false, "note": "任务已结束" }),
),
CancelOutcome::NotFound => rpc_envelope_error(
request_id,
ApiError::new(
ErrorCode::TASK_NOT_FOUND,
"task.cancel",
format!("任务不存在:{task_id}"),
),
),
}
}
RPC_METHOD_TASK_LOGS => {
let task_id = rpc_task_id_param(request);
match tasks.registry.logs(task_id) {
Some(lines) => rpc_envelope_from_result(
request_id,
"task.logs",
serde_json::to_value(serde_json::json!({ "task_id": task_id, "lines": lines }))
.map_err(anyhow::Error::from),
),
None => rpc_envelope_error(
request_id,
ApiError::new(
ErrorCode::TASK_NOT_FOUND,
"task.logs",
format!("任务不存在:{task_id}"),
),
),
}
}
RPC_METHOD_PATCH_APPLY => {
let params = match rpc_struct_params::<PatchApplyParams>(
request.params.as_ref(),
RPC_METHOD_PATCH_APPLY,
) {
Ok(params) => params,
Err(error) => return rpc_envelope_error(request_id, error),
};
rpc_envelope_from_result(
request_id,
RPC_METHOD_PATCH_APPLY,
apply_patch_file(&params)
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
)
}
RPC_METHOD_UNITYFS_PATCH_TEXT_ASSET => {
let params = match rpc_struct_params::<UnityFsTextAssetPatchParams>(
request.params.as_ref(),
RPC_METHOD_UNITYFS_PATCH_TEXT_ASSET,
) {
Ok(params) => params,
Err(error) => return rpc_envelope_error(request_id, error),
};
rpc_envelope_from_result(
request_id,
RPC_METHOD_UNITYFS_PATCH_TEXT_ASSET,
apply_unityfs_text_asset_patch_file(&params)
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
)
}
RPC_METHOD_UNITYFS_PATCH_STRING_FIELD => {
let params = match rpc_struct_params::<UnityFsStringFieldPatchParams>(
request.params.as_ref(),
RPC_METHOD_UNITYFS_PATCH_STRING_FIELD,
) {
Ok(params) => params,
Err(error) => return rpc_envelope_error(request_id, error),
};
rpc_envelope_from_result(
request_id,
RPC_METHOD_UNITYFS_PATCH_STRING_FIELD,
apply_unityfs_string_field_patch_file(&params)
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
)
}
RPC_METHOD_UNITYFS_PATCH_FIELD => {
let params = match rpc_struct_params::<UnityFsFieldPatchParams>(
request.params.as_ref(),
RPC_METHOD_UNITYFS_PATCH_FIELD,
) {
Ok(params) => params,
Err(error) => return rpc_envelope_error(request_id, error),
};
rpc_envelope_from_result(
request_id,
RPC_METHOD_UNITYFS_PATCH_FIELD,
apply_unityfs_field_patch_file(&params)
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
)
}
pending if is_pending_rpc_method(pending) => rpc_envelope_error(
request_id,
ApiError::new(
ErrorCode::RPC_NOT_IMPLEMENTED,
"rpc.dispatch",
format!("方法尚未实现:{pending}"),
),
),
unknown => rpc_envelope_error(
request_id,
ApiError::new(
ErrorCode::RPC_UNKNOWN_METHOD,
"rpc.dispatch",
format!("未知 RPC 方法:{unknown}"),
),
),
}
}
/// 创建任务、入队,返回 `accepted` + task_id 的 envelope。
fn enqueue_task_envelope(
tasks: &DaemonTaskContext,
kind: TaskKind,
force: bool,
request_id: String,
) -> RpcEnvelope {
let config = kind.build_config(&tasks.base_config, force);
let task_id = tasks.registry.create(kind);
let cancel = tasks
.registry
.cancel_flag(&task_id)
.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
let job = TaskJob {
id: task_id.clone(),
config,
cancel,
};
if tasks.queue.send(job).is_err() {
// worker 已退出:把该任务标记为失败并返回错误。
tasks.registry.update(&task_id, |record| {
record.status = "failed";
record.finished_at = Some(unix_seconds_now());
record.error = Some(ApiError::new(
ErrorCode::INTERNAL,
"task.enqueue",
"任务执行器不可用",
));
});
return rpc_envelope_error(
request_id,
ApiError::new(ErrorCode::INTERNAL, "task.enqueue", "任务执行器不可用"),
);
}
rpc_envelope_ok(
request_id,
"accepted",
serde_json::json!({ "task_id": task_id, "kind": kind.method() }),
)
}
fn rpc_ack_value(
command: &'static str,
message: &'static str,
state_dir: &Path,
force: Option<bool>,
) -> serde_json::Value {
serde_json::to_value(DaemonRpcAck {
command,
status: "accepted",
message,
state_dir: state_dir.to_path_buf(),
socket_path: daemon_socket_path(state_dir),
controller_pid: None,
force,
})
.unwrap_or(serde_json::Value::Null)
}
fn rpc_restart_ack_value(
command: &'static str,
message: &'static str,
state_dir: &Path,
controller_pid: u32,
) -> serde_json::Value {
serde_json::to_value(DaemonRpcAck {
command,
status: "accepted",
message,
state_dir: state_dir.to_path_buf(),
socket_path: daemon_socket_path(state_dir),
controller_pid: Some(controller_pid),
force: None,
})
.unwrap_or(serde_json::Value::Null)
}
/// 构建 `resource.state` 数据:资源发布根、版本状态、上次同步结果。
/// 读取 daemon 状态文件与资源根目录的版本状态(resource/catalog 只读查询共用)。
fn read_daemon_resource_state(
state_dir: &Path,
) -> anyhow::Result<(Option<DaemonStatusFile>, Option<OfficialVersionState>)> {
let status_file = read_daemon_status_file(&daemon_status_path(state_dir))?;
let version_state = status_file
.as_ref()
.map(|status| {
status
.resource_output_root
.join("official-version-state.json")
})
.and_then(|path| read_version_state(&path).ok().flatten());
Ok((status_file, version_state))
}
fn flow_status_fields(
code: ReleaseFlowStatusCode,
) -> (&'static str, &'static str, &'static str, bool, bool) {
(
code.status(),
code.as_str(),
code.phase(),
code.terminal(),
code.retryable(),
)
}
fn build_resource_state_report(state_dir: &Path) -> anyhow::Result<serde_json::Value> {
let (status_file, version_state) = read_daemon_resource_state(state_dir)?;
let flow_status_code = status_file
.as_ref()
.and_then(|status| status.status_code.as_deref())
.and_then(|code| code.parse::<ReleaseFlowStatusCode>().ok())
.or_else(|| {
if version_state
.as_ref()
.and_then(|state| state.in_progress_version.as_ref())
.is_some()
{
Some(ReleaseFlowStatusCode::OfficialDownloading)
} else if version_state
.as_ref()
.is_some_and(|state| !state.failed_versions.is_empty())
{
Some(ReleaseFlowStatusCode::OfficialFailed)
} else if version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref())
.is_some()
{
Some(ReleaseFlowStatusCode::OfficialPublished)
} else {
Some(ReleaseFlowStatusCode::OfficialUnavailable)
}
})
.unwrap_or(ReleaseFlowStatusCode::OfficialUnavailable);
let (status, status_code, status_phase, status_terminal, status_retryable) =
flow_status_fields(flow_status_code);
Ok(serde_json::json!({
"status": status,
"status_code": status_code,
"status_phase": status_phase,
"status_terminal": status_terminal,
"status_retryable": status_retryable,
"resource_output_root": status_file
.as_ref()
.map(|status| status.resource_output_root.clone()),
"version_state": version_state,
"last_update_status": status_file
.as_ref()
.and_then(|status| status.last_update_status.clone()),
"last_success_unix_seconds": status_file
.as_ref()
.and_then(|status| status.last_success_unix_seconds),
}))
}
/// `catalog.status`:当前已发布版本的 catalog 概览(读取其 snapshot)。
///
/// 没有已发布版本或 snapshot 不可读时返回 `available: false`(正常状态,
/// 不视为错误,便于 Go 层直接分支)。
fn build_catalog_status_report(state_dir: &Path) -> anyhow::Result<serde_json::Value> {
let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref());
let snapshot = current.and_then(|record| read_snapshot(&record.snapshot_path).ok().flatten());
let (Some(record), Some(snapshot)) = (current, snapshot) else {
let (status, status_code, status_phase, status_terminal, status_retryable) =
flow_status_fields(ReleaseFlowStatusCode::OfficialUnavailable);
let (distribution_status, distribution_status_code, _, _, _) =
flow_status_fields(ReleaseFlowStatusCode::DistributionBlocked);
return Ok(serde_json::json!({
"available": false,
"status": status,
"status_code": status_code,
"status_phase": status_phase,
"status_terminal": status_terminal,
"status_retryable": status_retryable,
"distribution_status": distribution_status,
"distribution_status_code": distribution_status_code,
}));
};
let official_seed_hash_marker_count = snapshot
.endpoint_markers
.iter()
.filter(|marker| marker.role == OfficialEndpointMarkerRole::OfficialSeedHash)
.count();
let (status, status_code, status_phase, status_terminal, status_retryable) =
flow_status_fields(ReleaseFlowStatusCode::OfficialPublished);
let (distribution_status, distribution_status_code, _, _, _) =
flow_status_fields(ReleaseFlowStatusCode::DistributionReady);
Ok(serde_json::json!({
"available": true,
"status": status,
"status_code": status_code,
"status_phase": status_phase,
"status_terminal": status_terminal,
"status_retryable": status_retryable,
"distribution_status": distribution_status,
"distribution_status_code": distribution_status_code,
"version": {
"id": record.id,
"completed_unix_seconds": record.completed_unix_seconds,
"resource_root": record.resource_root,
},
"app_version": snapshot.app_version,
"bundle_version": snapshot.bundle_version,
"connection_group_name": snapshot.connection_group_name,
"addressables_root": snapshot.addressables_root,
"endpoint_count": snapshot.endpoints.len(),
"endpoint_marker_count": snapshot.endpoint_markers.len(),
"official_seed_hash_marker_count": official_seed_hash_marker_count,
"addressables_catalog_marker_count": snapshot.addressables_marker_checked_count(),
"launcher_metadata": snapshot.launcher_metadata,
"game_main_config_bootstrap": snapshot.game_main_config_bootstrap,
"snapshot_version": snapshot.snapshot_version,
}))
}
/// `catalog.versions`:版本历史(当前/进行中/上一个可用/失败记录)。
fn build_catalog_versions_report(state_dir: &Path) -> anyhow::Result<serde_json::Value> {
let (status_file, version_state) = read_daemon_resource_state(state_dir)?;
let Some(state) = version_state else {
return Ok(serde_json::json!({ "available": false }));
};
Ok(serde_json::json!({
"available": true,
"resource_output_root": status_file.map(|status| status.resource_output_root),
"current": state.current_completed_version,
"in_progress": state.in_progress_version,
"previous": state.previous_available_version,
"failed": state.failed_versions,
"updated_unix_seconds": state.updated_unix_seconds,
}))
}
/// `catalog.diff`:当前已发布 snapshot 相对上一个可用版本的差异。
///
/// 没有上一个版本(或其 snapshot 不可读,见 `previous_snapshot_missing`)时
/// 按首次观察处理(`base_delta.is_initial = true`)。
fn build_catalog_diff_report(state_dir: &Path) -> anyhow::Result<serde_json::Value> {
let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current_record = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref());
let current_snapshot =
current_record.and_then(|record| read_snapshot(&record.snapshot_path).ok().flatten());
let (Some(current_record), Some(current_snapshot)) = (current_record, current_snapshot) else {
return Ok(serde_json::json!({ "available": false }));
};
let previous_record = version_state
.as_ref()
.and_then(|state| state.previous_available_version.as_ref());
let previous_snapshot =
previous_record.and_then(|record| read_snapshot(&record.snapshot_path).ok().flatten());
let translation_status_code = if previous_record.is_some() {
ReleaseFlowStatusCode::TranslationQueuedOffline
} else {
ReleaseFlowStatusCode::TranslationUnavailable
};
let (status, status_code, status_phase, status_terminal, status_retryable) =
flow_status_fields(ReleaseFlowStatusCode::ParseCompleted);
let (translation_status, translation_status_code, _, _, _) =
flow_status_fields(translation_status_code);
let current_base = current_snapshot.base_snapshot();
let previous_base = previous_snapshot
.as_ref()
.map(OfficialUpdateSnapshot::base_snapshot);
let base_delta = current_base.diff(previous_base.as_ref());
let extended_delta = diff_extended_snapshot(&current_snapshot, previous_snapshot.as_ref());
let changed_urls = changed_endpoint_urls(&base_delta);
Ok(serde_json::json!({
"available": true,
"status": status,
"status_code": status_code,
"status_phase": status_phase,
"status_terminal": status_terminal,
"status_retryable": status_retryable,
"translation_status": translation_status,
"translation_status_code": translation_status_code,
"current_version_id": current_record.id,
"previous_version_id": previous_record.map(|record| record.id.clone()),
"previous_snapshot_missing": previous_record.is_some() && previous_snapshot.is_none(),
"base_delta": base_delta,
"extended_delta": extended_delta,
"changed_endpoint_urls": changed_urls,
}))
}
/// `resource.manifest`:当前版本下载 manifest 的分页查询。
fn build_resource_manifest_report(
state_dir: &Path,
offset: usize,
limit: usize,
) -> anyhow::Result<serde_json::Value> {
let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref());
let Some(record) = current else {
return Ok(serde_json::json!({ "available": false }));
};
let manifest = read_download_manifest_at(&record.resource_root).map_err(anyhow::Error::msg)?;
let Some(manifest) = manifest else {
return Ok(serde_json::json!({
"available": false,
"resource_root": record.resource_root,
}));
};
let total_entries = manifest.entries.len();
// BTreeMap 按 URL 有序迭代,分页结果稳定。
let entries: Vec<_> = manifest
.entries
.values()
.skip(offset)
.take(limit)
.cloned()
.collect();
Ok(serde_json::json!({
"available": true,
"resource_root": record.resource_root,
"manifest_version": manifest.version,
"total_entries": total_entries,
"offset": offset,
"limit": limit,
"entries": entries,
}))
}
/// `resource.index`:当前 ResourceRepository 的分页/过滤查询。
fn build_resource_index_report(
state_dir: &Path,
base_config: &OfficialUpdateConfig,
query: ResourceQuery,
offset: usize,
limit: usize,
) -> anyhow::Result<serde_json::Value> {
let (status_file, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref());
let repository_path = resource_repository_path_for_status(status_file.as_ref(), base_config);
if !repository_file_exists_no_symlink(&repository_path)? {
return Ok(serde_json::json!({
"available": false,
"current_version_id": current.map(|record| record.id.clone()),
"repository_path": repository_path,
"import_enabled": base_config.import_repository,
}));
}
let Some(record) = current else {
return Ok(serde_json::json!({
"available": false,
"repository_path": repository_path,
"import_enabled": base_config.import_repository,
}));
};
let (total_entries, entries) =
query_resource_repository(&repository_path, query.clone(), offset, limit)?;
Ok(serde_json::json!({
"available": true,
"current_version_id": record.id,
"resource_root": record.resource_root,
"repository_path": repository_path,
"import_enabled": base_config.import_repository,
"total_entries": total_entries,
"offset": offset,
"limit": limit,
"query": {
"resource_type": query.resource_type,
"hash": query.hash,
"path_pattern": query.path_pattern,
"official_release_id": query.official_release_id,
"platform": query.platform,
"destination": query.destination,
"bundle_path": query.bundle_path,
"archive_entry": query.archive_entry,
"parse_status": query.parse_status,
"text_unit_format": query.text_unit_format,
},
"entries": entries,
}))
}
fn resource_repository_path_for_status(
status_file: Option<&DaemonStatusFile>,
base_config: &OfficialUpdateConfig,
) -> PathBuf {
base_config
.import_resource_repository_path
.clone()
.or_else(|| status_file.map(|status| status.resource_output_root.join("resources.sqlite")))
.unwrap_or_else(|| base_config.effective_import_resource_repository_path())
}
fn query_resource_repository(
repository_path: &Path,
query: ResourceQuery,
offset: usize,
limit: usize,
) -> anyhow::Result<(u64, Vec<Resource>)> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(async {
let repository = SqliteResourceRepository::new(repository_path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let total_entries = repository
.count(query.clone())
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let entries = repository
.list(query)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?
.into_iter()
.skip(offset)
.take(limit)
.collect();
Ok((total_entries, entries))
})
}
fn query_translation_task_repository(
repository_path: &Path,
query: &OfficialTextUnitTaskQuery,
offset: usize,
limit: usize,
) -> anyhow::Result<(u64, Vec<bat_infrastructure::PersistedTranslationTask>)> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(async {
let repository = SqliteTranslationTaskRepository::open(repository_path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let total_entries = repository
.count(query)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let entries = repository
.list(query)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?
.into_iter()
.skip(offset)
.take(limit)
.collect();
Ok((total_entries, entries))
})
}
fn repository_file_exists_no_symlink(path: &Path) -> anyhow::Result<bool> {
sqlite_file_exists_no_symlink(path, "资源索引数据库")
}
fn sqlite_file_exists_no_symlink(path: &Path, label: &str) -> anyhow::Result<bool> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
Err(anyhow::anyhow!("{label}不能是 symlink{}", path.display()))
}
Ok(metadata) if metadata.is_file() => Ok(true),
Ok(_) => Err(anyhow::anyhow!("{label}不是普通文件:{}", path.display())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
/// `parse.status`:当前已发布版本的解析缓存摘要。
fn build_parse_status_report(state_dir: &Path) -> anyhow::Result<serde_json::Value> {
let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref());
let Some(record) = current else {
let (status, status_code, status_phase, status_terminal, status_retryable) =
flow_status_fields(ReleaseFlowStatusCode::ParseBlockedOfficial);
let (translation_status, translation_status_code, _, _, _) =
flow_status_fields(ReleaseFlowStatusCode::TranslationUnavailable);
return Ok(serde_json::json!({
"available": false,
"status": status,
"status_code": status_code,
"status_phase": status_phase,
"status_terminal": status_terminal,
"status_retryable": status_retryable,
"translation_status": translation_status,
"translation_status_code": translation_status_code,
}));
};
let cache_path = record.resource_root.join(OFFICIAL_PARSE_CACHE_FILE);
let Some(cache) = read_parse_cache_at(&record.resource_root).map_err(anyhow::Error::msg)?
else {
let (status, status_code, status_phase, status_terminal, status_retryable) =
flow_status_fields(ReleaseFlowStatusCode::ParsePending);
let (translation_status, translation_status_code, _, _, _) =
flow_status_fields(ReleaseFlowStatusCode::TranslationUnavailable);
return Ok(serde_json::json!({
"available": false,
"status": status,
"status_code": status_code,
"status_phase": status_phase,
"status_terminal": status_terminal,
"status_retryable": status_retryable,
"translation_status": translation_status,
"translation_status_code": translation_status_code,
"current_version_id": record.id,
"resource_root": record.resource_root,
"cache_path": cache_path,
}));
};
let textunit_queue_path = record
.resource_root
.join(bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE);
let textunit_queue = bat_infrastructure::read_textunit_task_queue_at(&record.resource_root)
.map_err(anyhow::Error::msg)?;
let textunit_index_path = record.resource_root.join(OFFICIAL_TEXTUNIT_INDEX_FILE);
let textunit_index =
read_textunit_index_at(&record.resource_root).map_err(anyhow::Error::msg)?;
let parse_status_code = if cache.summary.failed_count > 0 {
ReleaseFlowStatusCode::ParseCompletedWithErrors
} else {
ReleaseFlowStatusCode::ParseCompleted
};
let queue_summary = textunit_queue.as_ref().map(|queue| &queue.summary);
let translation_status_code =
if queue_summary.is_some_and(|summary| summary.queued_task_count > 0) {
ReleaseFlowStatusCode::TranslationQueuedOffline
} else {
ReleaseFlowStatusCode::TranslationUnavailable
};
let (status, status_code, status_phase, status_terminal, status_retryable) =
flow_status_fields(parse_status_code);
let (translation_status, translation_status_code, _, _, _) =
flow_status_fields(translation_status_code);
Ok(serde_json::json!({
"available": true,
"status": status,
"status_code": status_code,
"status_phase": status_phase,
"status_terminal": status_terminal,
"status_retryable": status_retryable,
"translation_status": translation_status,
"translation_status_code": translation_status_code,
"current_version_id": record.id,
"resource_root": record.resource_root,
"cache_path": cache_path,
"cache_version": cache.version,
"generated_unix_seconds": cache.generated_unix_seconds,
"summary": cache.summary,
"textunit_queue_available": textunit_queue.is_some(),
"textunit_task_queue_path": textunit_queue_path,
"textunit_task_summary": textunit_queue.map(|queue| queue.summary),
"textunit_index_available": textunit_index.is_some(),
"textunit_index_path": textunit_index_path,
"textunit_index_summary": textunit_index.map(|index| index.summary),
}))
}
/// `parse.text_units`:当前已发布版本的 TextUnit 明细查询。
fn build_parse_text_units_report(
state_dir: &Path,
query: OfficialTextUnitQuery,
offset: usize,
limit: usize,
) -> anyhow::Result<serde_json::Value> {
let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref());
let Some(record) = current else {
return Ok(serde_json::json!({ "available": false }));
};
let index_path = record.resource_root.join(OFFICIAL_TEXTUNIT_INDEX_FILE);
let Some(index) = read_textunit_index_at(&record.resource_root).map_err(anyhow::Error::msg)?
else {
return Ok(serde_json::json!({
"available": false,
"current_version_id": record.id,
"resource_root": record.resource_root,
"textunit_index_path": index_path,
}));
};
let matches = bat_infrastructure::query_textunit_index_units(&index, &query);
let total_entries = matches.len();
let entries = matches
.into_iter()
.skip(offset)
.take(limit)
.cloned()
.collect::<Vec<_>>();
Ok(serde_json::json!({
"available": true,
"current_version_id": record.id,
"resource_root": record.resource_root,
"textunit_index_path": index_path,
"summary": index.summary,
"total_entries": total_entries,
"offset": offset,
"limit": limit,
"query": textunit_query_json(&query),
"entries": entries,
}))
}
/// `parse.errors`:当前已发布版本的解析/提取诊断查询。
fn build_parse_errors_report(
state_dir: &Path,
query: OfficialTextUnitQuery,
offset: usize,
limit: usize,
) -> anyhow::Result<serde_json::Value> {
let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref());
let Some(record) = current else {
return Ok(serde_json::json!({ "available": false }));
};
let index_path = record.resource_root.join(OFFICIAL_TEXTUNIT_INDEX_FILE);
let Some(index) = read_textunit_index_at(&record.resource_root).map_err(anyhow::Error::msg)?
else {
return Ok(serde_json::json!({
"available": false,
"current_version_id": record.id,
"resource_root": record.resource_root,
"textunit_index_path": index_path,
}));
};
let matches = bat_infrastructure::query_textunit_index_errors(&index, &query);
let total_entries = matches.len();
let entries = matches
.into_iter()
.skip(offset)
.take(limit)
.cloned()
.collect::<Vec<_>>();
Ok(serde_json::json!({
"available": true,
"current_version_id": record.id,
"resource_root": record.resource_root,
"textunit_index_path": index_path,
"summary": index.summary,
"total_entries": total_entries,
"offset": offset,
"limit": limit,
"query": textunit_query_json(&query),
"entries": entries,
}))
}
/// `localized.status`:当前官方版本对应的汉化 release 状态。
fn build_localized_status_report(
state_dir: &Path,
base_config: &OfficialUpdateConfig,
) -> anyhow::Result<serde_json::Value> {
let (status_file, version_state) = read_daemon_resource_state(state_dir)?;
let official_version_id = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref())
.map(|record| record.id.clone());
let localized_root = status_file
.as_ref()
.and_then(|status| status.localized_output_root.clone())
.unwrap_or_else(|| base_config.localized_output_root.clone());
let state_path = localized_root.join(LOCALIZED_VERSION_STATE_FILE);
let current_path = localized_root.join(LOCALIZED_CURRENT_LINK);
let state = read_localized_version_state(&localized_root)?;
let mut localized_release_status = "not_localized";
let mut published_version_path = None;
let mut matches_current_official_release = false;
let mut current_points_to_published_version = false;
let mut patch_manifest_path = None;
let mut patch_manifest_available = false;
let mut patch_manifest_matches_release = false;
let mut patch_file_count = None;
let mut patch_text_asset_operation_count = None;
let mut rollback_previous_current_target = None;
let mut flow_status_code = if official_version_id.is_some() {
ReleaseFlowStatusCode::LocalizedPending
} else {
ReleaseFlowStatusCode::LocalizedBlockedOfficial
};
if let Some(localized_state) = state.as_ref() {
matches_current_official_release = official_version_id
.as_deref()
.is_some_and(|id| localized_state.official_release_id == id);
if official_version_id.is_some() && !matches_current_official_release {
flow_status_code = ReleaseFlowStatusCode::LocalizedStale;
}
if localized_state.status == "localized" && matches_current_official_release {
if let Some(release_id) = localized_state.current_release_id.as_deref() {
let candidate = localized_root.join(LOCALIZED_VERSIONS_DIR).join(release_id);
current_points_to_published_version =
localized_current_points_to(&current_path, &candidate);
let manifest_path = candidate.join(LOCALIZED_PATCH_MANIFEST_FILE);
patch_manifest_path = Some(manifest_path);
if let Some(manifest) = read_localized_patch_manifest_at(&candidate)? {
patch_manifest_available = true;
patch_manifest_matches_release = official_version_id
.as_deref()
.is_some_and(|id| manifest.official_release_id == id)
&& manifest.localized_release_id == release_id;
patch_file_count = Some(manifest.file_count);
patch_text_asset_operation_count = Some(manifest.text_asset_operation_count);
rollback_previous_current_target = manifest.rollback.previous_current_target;
}
if candidate.is_dir()
&& current_points_to_published_version
&& patch_manifest_matches_release
{
localized_release_status = "localized";
flow_status_code = ReleaseFlowStatusCode::LocalizedPublished;
published_version_path = Some(candidate);
}
}
}
}
let (status, status_code, status_phase, status_terminal, status_retryable) =
flow_status_fields(flow_status_code);
Ok(serde_json::json!({
"available": state.is_some(),
"status": status,
"status_code": status_code,
"status_phase": status_phase,
"status_terminal": status_terminal,
"status_retryable": status_retryable,
"localized_release_status": localized_release_status,
"official_current_version_id": official_version_id,
"localized_output_root": localized_root,
"state_path": state_path,
"current_path": current_path,
"published_version_path": published_version_path,
"patch_manifest_path": patch_manifest_path,
"patch_manifest_available": patch_manifest_available,
"patch_manifest_matches_release": patch_manifest_matches_release,
"patch_file_count": patch_file_count,
"patch_text_asset_operation_count": patch_text_asset_operation_count,
"rollback_previous_current_target": rollback_previous_current_target,
"matches_current_official_release": matches_current_official_release,
"current_points_to_published_version": current_points_to_published_version,
"state": state,
}))
}
fn localized_current_points_to(current_path: &Path, version_path: &Path) -> bool {
let Ok(target) = fs::read_link(current_path) else {
return false;
};
let resolved = if target.is_absolute() {
target
} else {
current_path
.parent()
.map(|parent| parent.join(&target))
.unwrap_or(target)
};
resolved == version_path
}
#[cfg(unix)]
fn write_json_rpc_response(
stream: &mut UnixStream,
response: &JsonRpcResponse,
) -> anyhow::Result<()> {
serde_json::to_writer(&mut *stream, response)?;
stream.write_all(b"\n")?;
stream.flush()?;
Ok(())
}
fn json_rpc_result(id: Option<serde_json::Value>, result: serde_json::Value) -> JsonRpcResponse {
JsonRpcResponse {
jsonrpc: "2.0",
id,
result: Some(result),
error: None,
}
}
fn json_rpc_error(id: Option<serde_json::Value>, code: i32, message: String) -> JsonRpcResponse {
JsonRpcResponse {
jsonrpc: "2.0",
id,
result: None,
error: Some(JsonRpcError { code, message }),
}
}
fn rpc_task_id_param(request: &JsonRpcRequest) -> &str {
request
.params
.as_ref()
.and_then(|params| params.get("task_id"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
}
fn rpc_bool_param(params: Option<&serde_json::Value>, key: &str) -> Option<bool> {
params
.and_then(|params| params.get(key))
.and_then(serde_json::Value::as_bool)
}
fn rpc_struct_params<T: DeserializeOwned>(
params: Option<&serde_json::Value>,
method: &'static str,
) -> Result<T, ApiError> {
let Some(params) = params else {
return Err(ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
method,
"params 不能为空",
));
};
serde_json::from_value(params.clone()).map_err(|error| {
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
method,
format!("params 无效:{error}"),
)
})
}
/// 分页参数:`offset` 默认 0`limit` 默认 100,范围 1..=1000。
fn rpc_page_params(params: Option<&serde_json::Value>) -> anyhow::Result<(usize, usize)> {
let offset = params
.and_then(|params| params.get("offset"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let limit = params
.and_then(|params| params.get("limit"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(100);
let offset = usize::try_from(offset)?;
let limit = usize::try_from(limit)?;
if limit == 0 || limit > 1000 {
return Err(anyhow::anyhow!("limit 必须在 1..=1000 范围内"));
}
Ok((offset, limit))
}
fn rpc_resource_index_params(
params: Option<&serde_json::Value>,
) -> anyhow::Result<(ResourceQuery, usize, usize)> {
let (offset, limit) = rpc_page_params(params)?;
let resource_type = rpc_string_param(params, "resource_type")
.or_else(|| rpc_string_param(params, "type"))
.map(parse_resource_type_param)
.transpose()?;
let query = ResourceQuery {
resource_type,
hash: rpc_string_param(params, "hash").map(str::to_string),
path_pattern: rpc_string_param(params, "path_pattern").map(str::to_string),
official_release_id: rpc_string_param(params, "official_release_id")
.or_else(|| rpc_string_param(params, "release_id"))
.map(str::to_string),
platform: rpc_string_param(params, "platform").map(str::to_string),
destination: rpc_string_param(params, "destination").map(str::to_string),
bundle_path: rpc_string_param(params, "bundle_path").map(str::to_string),
archive_entry: rpc_string_param(params, "archive_entry").map(str::to_string),
parse_status: rpc_string_param(params, "parse_status").map(str::to_string),
text_unit_format: rpc_string_param(params, "text_unit_format")
.or_else(|| rpc_string_param(params, "format"))
.map(str::to_string),
};
Ok((query, offset, limit))
}
fn rpc_textunit_query_params(
params: Option<&serde_json::Value>,
) -> anyhow::Result<(OfficialTextUnitQuery, usize, usize)> {
let (offset, limit) = rpc_page_params(params)?;
let query = OfficialTextUnitQuery {
destination: rpc_string_param(params, "destination").map(str::to_string),
path_pattern: rpc_string_param(params, "path_pattern").map(str::to_string),
archive_entry: rpc_string_param(params, "archive_entry").map(str::to_string),
path_id: rpc_i64_param(params, "path_id")?,
class_id: rpc_i32_param(params, "class_id")?,
field_path: rpc_string_param(params, "field_path").map(str::to_string),
format: rpc_string_param(params, "format").map(str::to_string),
};
Ok((query, offset, limit))
}
fn rpc_translation_task_query_params(
params: Option<&serde_json::Value>,
) -> anyhow::Result<(OfficialTextUnitTaskQuery, usize, usize)> {
let (offset, limit) = rpc_page_params(params)?;
let query = OfficialTextUnitTaskQuery {
task_id: rpc_string_param(params, "task_id").map(str::to_string),
official_release_id: rpc_string_param(params, "official_release_id")
.or_else(|| rpc_string_param(params, "release_id"))
.map(str::to_string),
destination: rpc_string_param(params, "destination").map(str::to_string),
path_pattern: rpc_string_param(params, "path_pattern").map(str::to_string),
archive_entry: rpc_string_param(params, "archive_entry").map(str::to_string),
status: rpc_string_param(params, "status")
.or_else(|| rpc_string_param(params, "task_status"))
.map(str::to_string),
task_status: rpc_string_param(params, "worker_status").map(str::to_string),
parse_status: rpc_string_param(params, "parse_status").map(str::to_string),
text_unit_format: rpc_string_param(params, "text_unit_format")
.or_else(|| rpc_string_param(params, "format"))
.map(str::to_string),
has_reason: rpc_bool_param(params, "has_reason"),
has_failure_reason: rpc_bool_param(params, "has_failure_reason"),
};
Ok((query, offset, limit))
}
fn rpc_string_param<'a>(params: Option<&'a serde_json::Value>, key: &str) -> Option<&'a str> {
params
.and_then(|params| params.get(key))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn rpc_i64_param(params: Option<&serde_json::Value>, key: &str) -> anyhow::Result<Option<i64>> {
let Some(value) = params.and_then(|params| params.get(key)) else {
return Ok(None);
};
if let Some(number) = value.as_i64() {
return Ok(Some(number));
}
if let Some(text) = value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return text
.parse::<i64>()
.map(Some)
.map_err(|error| anyhow::anyhow!("{key} 无效:{error}"));
}
Err(anyhow::anyhow!("{key} 必须是整数"))
}
fn rpc_i32_param(params: Option<&serde_json::Value>, key: &str) -> anyhow::Result<Option<i32>> {
let Some(value) = rpc_i64_param(params, key)? else {
return Ok(None);
};
Ok(Some(
i32::try_from(value).map_err(|_| anyhow::anyhow!("{key} 超出 i32 范围"))?,
))
}
fn parse_resource_type_param(value: &str) -> anyhow::Result<ResourceType> {
let normalized = value
.chars()
.filter(|ch| !matches!(ch, '_' | '-' | ' '))
.collect::<String>()
.to_ascii_lowercase();
match normalized.as_str() {
"assetbundle" => Ok(ResourceType::AssetBundle),
"manifest" => Ok(ResourceType::Manifest),
"tablebundle" => Ok(ResourceType::TableBundle),
"textasset" => Ok(ResourceType::TextAsset),
"media" => Ok(ResourceType::Media),
"other" => Ok(ResourceType::Other),
_ => Err(anyhow::anyhow!("不支持的 resource_type{value}")),
}
}
fn parse_patch_apply_kind(value: &str) -> anyhow::Result<PatchApplyKind> {
let normalized = value
.chars()
.filter(|ch| !matches!(ch, '_' | '-' | ' '))
.collect::<String>()
.to_ascii_lowercase();
match normalized.as_str() {
"binary" => Ok(PatchApplyKind::Binary),
"json" => Ok(PatchApplyKind::Json),
"text" => Ok(PatchApplyKind::Text),
_ => Err(anyhow::anyhow!(
"不支持的 patch kind{value},可用值为 binary/json/text"
)),
}
}
fn parse_replacement_value_json(
value: &str,
flag: &str,
) -> anyhow::Result<UnitySerializedReplacementValue> {
serde_json::from_str(value).map_err(|error| anyhow::anyhow!("{flag} JSON 无效:{error}"))
}
fn rpc_tail_param(params: Option<&serde_json::Value>, default: usize) -> anyhow::Result<usize> {
let tail = params
.and_then(|params| params.get("tail"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(default as u64);
let tail = usize::try_from(tail)?;
if tail == 0 {
return Err(anyhow::anyhow!("tail 必须大于 0"));
}
Ok(tail)
}
#[cfg(unix)]
fn daemon_rpc_call(
state_dir: &Path,
method: &str,
params: Option<serde_json::Value>,
) -> anyhow::Result<serde_json::Value> {
let socket_path = daemon_socket_path(state_dir);
ensure_daemon_socket_not_symlink(&socket_path)?;
let mut stream = UnixStream::connect(&socket_path).map_err(|error| {
anyhow::anyhow!("无法连接后台 RPC socket {}{error}", socket_path.display())
})?;
let request = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params.unwrap_or(serde_json::Value::Null),
});
serde_json::to_writer(&mut stream, &request)?;
stream.write_all(b"\n")?;
stream.flush()?;
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line)?;
if line.trim().is_empty() {
return Err(anyhow::anyhow!("后台 RPC socket 未返回响应"));
}
let response: JsonRpcClientResponse = serde_json::from_str(&line)?;
if let Some(error) = response.error {
return Err(anyhow::anyhow!(
"后台 RPC {} 传输层失败:{} ({})",
method,
error.message,
error.code
));
}
// 解包应用层 envelope:检查 ok,成功返回 data,失败把 envelope.error 转为 Err。
let envelope = response
.result
.ok_or_else(|| anyhow::anyhow!("后台 RPC {method} 未返回 result"))?;
let ok = envelope
.get("ok")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if !ok {
let error = envelope.get("error");
let code = error
.and_then(|error| error.get("code"))
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let message = error
.and_then(|error| error.get("message"))
.and_then(serde_json::Value::as_str)
.unwrap_or("未知错误");
return Err(anyhow::anyhow!(
"后台 RPC {method} 失败:[{code}] {message}"
));
}
Ok(envelope
.get("data")
.cloned()
.unwrap_or(serde_json::Value::Null))
}
#[cfg(not(unix))]
fn daemon_rpc_call(
_state_dir: &Path,
_method: &str,
_params: Option<serde_json::Value>,
) -> anyhow::Result<serde_json::Value> {
Err(anyhow::anyhow!("daemon RPC 目前只支持 Unix/Linux 平台"))
}
#[cfg(unix)]
fn daemon_rpc_available(state_dir: &Path) -> bool {
let socket_path = daemon_socket_path(state_dir);
ensure_daemon_socket_not_symlink(&socket_path).is_ok()
&& UnixStream::connect(socket_path).is_ok()
}
#[cfg(not(unix))]
fn daemon_rpc_available(_state_dir: &Path) -> bool {
false
}
#[cfg(unix)]
fn ensure_daemon_socket_not_symlink(socket_path: &Path) -> anyhow::Result<()> {
match fs::symlink_metadata(socket_path) {
Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow::anyhow!(
"后台 RPC socket 不能是 symlink{}",
socket_path.display()
)),
Ok(_) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
#[cfg(unix)]
fn daemon_socket_path_exists(socket_path: &Path) -> anyhow::Result<bool> {
path_exists_no_follow(socket_path).map_err(|error| {
anyhow::anyhow!(
"后台 RPC socket 路径检查失败 {}{error}",
socket_path.display()
)
})
}
#[cfg(not(unix))]
fn daemon_socket_path_exists(socket_path: &Path) -> anyhow::Result<bool> {
path_exists_no_follow(socket_path)
}
fn path_exists_no_follow(path: &Path) -> anyhow::Result<bool> {
match fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
#[derive(Debug, Serialize)]
struct DaemonStartReport {
status: &'static str,
message: &'static str,
pid: u32,
resource_output_root: PathBuf,
localized_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>,
localized_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>,
status_code: 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 localized_output_root = normalized_abs_path(&options.config.localized_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,
localized_output_root,
args,
proxy_url,
"后台同步已启动",
)
}
fn spawn_daemon_restart_controller(state_dir: &Path) -> anyhow::Result<u32> {
validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?;
fs::create_dir_all(state_dir)?;
let executable = env::current_exe()?;
let log = open_append_file(&daemon_log_path(state_dir), PRIVATE_FILE_MODE, "后台日志")
.map_err(anyhow::Error::msg)?;
let log_for_stdout = log.try_clone()?;
let mut command = Command::new(executable);
command
.arg("restart")
.arg("--state-dir")
.arg(state_dir)
.arg("--json")
.arg("--no-progress")
.arg("--no-banner")
.stdin(Stdio::null())
.stdout(Stdio::from(log_for_stdout))
.stderr(Stdio::from(log));
configure_daemon_command(&mut command);
let child = command.spawn()?;
Ok(child.id())
}
fn start_daemon_with_args(
state_dir: PathBuf,
resource_output_root: PathBuf,
localized_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(),
localized_output_root: Some(localized_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,
status_code: 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,
localized_output_root,
state_dir,
pid_path,
status_path,
log_path,
structured_log_path,
socket_path,
})
}
fn print_daemon_status(state_dir: &Path, output_format: OutputFormat) -> anyhow::Result<()> {
if let Ok(report) = daemon_rpc_call(state_dir, RPC_METHOD_STATUS, None) {
print_json_value(output_format, &report)?;
} else {
let report = build_daemon_status_report(state_dir)?;
print_report(output_format, &report)?;
}
Ok(())
}
fn stop_daemon(state_dir: &Path, output_format: OutputFormat) -> anyhow::Result<()> {
if daemon_rpc_available(state_dir) {
let pid = read_pid_file(&daemon_pid_path(state_dir))?.or_else(|| {
read_daemon_status_file(&daemon_status_path(state_dir))
.ok()
.flatten()
.map(|status| status.pid)
});
if daemon_rpc_call(state_dir, RPC_METHOD_STOP, None).is_ok() {
let forced_signal = if let Some(pid) = pid {
wait_for_rpc_stop_or_terminate(pid, Duration::from_secs(10))?
} else {
false
};
let pid_path = daemon_pid_path(state_dir);
let socket_path = daemon_socket_path(state_dir);
let _ = fs::remove_file(&pid_path);
let _ = fs::remove_file(&socket_path);
let report = DaemonStopReport {
status: if forced_signal {
"stopped_after_signal"
} else {
"stopped"
},
message: if forced_signal {
"后台进程已接收 RPC 停止请求,但未在优雅窗口内退出,已发送 SIGTERM 后停止"
} else {
"后台进程已通过 RPC 停止"
},
stopped: true,
pid,
state_dir: state_dir.to_path_buf(),
pid_path,
socket_path,
};
print_report(output_format, &report)?;
return Ok(());
}
}
// 非 RPC 路径:stop_daemon_inner 内部会通过 classify_pid_lock_file 校验并分类
// PID 文件(含 symlink 拒绝),无需在此重复读取。
let report = stop_daemon_inner(state_dir)?;
print_report(output_format, &report)?;
Ok(())
}
fn stop_daemon_inner(state_dir: &Path) -> anyhow::Result<DaemonStopReport> {
let pid_path = daemon_pid_path(state_dir);
let pid_state = classify_pid_lock_file(&pid_path)?;
let (pid, running) = match pid_state {
PidLockState::Missing => {
return Ok(DaemonStopReport {
status: "not_running",
message: "后台进程当前未运行",
stopped: false,
pid: None,
state_dir: state_dir.to_path_buf(),
pid_path,
socket_path: daemon_socket_path(state_dir),
});
}
PidLockState::Corrupt => {
let _ = fs::remove_file(&pid_path);
let _ = fs::remove_file(daemon_socket_path(state_dir));
return Ok(DaemonStopReport {
status: "stale_pid_removed",
message: "已清理无效的后台 PID 文件",
stopped: false,
pid: None,
state_dir: state_dir.to_path_buf(),
pid_path,
socket_path: daemon_socket_path(state_dir),
});
}
PidLockState::StalePid(pid) => (pid, false),
PidLockState::Active(pid) => {
terminate_process(pid)?;
if !wait_for_process_exit(pid, Duration::from_secs(5)) {
return Err(anyhow::anyhow!("后台进程 pid={pid} 在 5 秒内未停止"));
}
(pid, true)
}
};
let _ = fs::remove_file(&pid_path);
let _ = fs::remove_file(daemon_socket_path(state_dir));
Ok(DaemonStopReport {
status: if running {
"stopped"
} else {
"stale_pid_removed"
},
message: if running {
"后台进程已停止"
} else {
"已清理失效的后台 PID 文件"
},
stopped: running,
pid: Some(pid),
state_dir: state_dir.to_path_buf(),
pid_path,
socket_path: daemon_socket_path(state_dir),
})
}
fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusReport> {
let pid_path = daemon_pid_path(state_dir);
let status_path = daemon_status_path(state_dir);
let socket_path = daemon_socket_path(state_dir);
let status_file = read_daemon_status_file(&status_path)?;
let pid_file_state = classify_pid_lock_file(&pid_path)?;
let pid_file_pid = match pid_file_state {
PidLockState::Active(pid) | PidLockState::StalePid(pid) => Some(pid),
PidLockState::Missing | PidLockState::Corrupt => None,
};
let pid = pid_file_pid.or_else(|| status_file.as_ref().map(|status| status.pid));
let running = pid.map(process_exists).unwrap_or(false);
let rpc_available = daemon_rpc_available(state_dir);
let stale_pid_file = matches!(
pid_file_state,
PidLockState::StalePid(_) | PidLockState::Corrupt
);
let stale_socket = daemon_socket_path_exists(&socket_path)? && !rpc_available;
let default_log_path = daemon_log_path(state_dir);
let default_log_exists = path_exists_no_follow(&default_log_path)?;
let structured_log_path = status_file
.as_ref()
.and_then(|status| status.structured_log_path.clone())
.unwrap_or_else(|| daemon_structured_log_path(state_dir));
let structured_log_exists = path_exists_no_follow(&structured_log_path)?;
let rotated_structured_log_paths = rotated_structured_log_paths(&structured_log_path);
let version_state_path = status_file.as_ref().map(|status| {
status
.resource_output_root
.join("official-version-state.json")
});
let version_state = version_state_path
.as_ref()
.and_then(|path| read_version_state(path).ok().flatten());
Ok(DaemonStatusReport {
status: if running { "running" } else { "stopped" },
message: if running {
"后台进程正在运行"
} else if stale_pid_file || stale_socket {
"后台进程当前未运行,但检测到失效 PID/socket;可执行 clean-stable 清理"
} else {
"后台进程当前未运行"
},
running,
pid,
resource_output_root: status_file
.as_ref()
.map(|status| status.resource_output_root.clone()),
localized_output_root: status_file
.as_ref()
.and_then(|status| status.localized_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()),
status_code: status_file
.as_ref()
.and_then(|status| status.status_code.clone()),
current_message: status_file
.as_ref()
.and_then(|status| status.current_message.clone()),
download_progress: status_file
.as_ref()
.and_then(|status| status.download_progress.clone()),
version_state_path,
version_state,
pending_scheduled_force: status_file
.as_ref()
.map(|status| status.pending_scheduled_force),
next_forced_refresh_unix_seconds: status_file
.as_ref()
.and_then(|status| status.next_forced_refresh_unix_seconds),
command: status_file.map(|status| redact_command_proxy_credentials(status.command)),
})
}
/// Redacts proxy credentials in a saved daemon command before it is surfaced to
/// callers (RPC `status`, `bat status --json`, human output).
///
/// The on-disk status file keeps the raw command because `restart`/`reload`
/// relaunch the daemon from it; only the externally visible copy is redacted so
/// credentials never reach stdout, monitoring, or log collection.
fn redact_command_proxy_credentials(command: Vec<String>) -> Vec<String> {
let mut redacted = command;
for index in 0..redacted.len() {
if redacted[index] == "--proxy" {
if let Some(value) = redacted.get_mut(index + 1) {
*value = redact_proxy_url(value);
}
}
}
redacted
}
#[derive(Debug, Serialize)]
struct DaemonControlReport {
command: &'static str,
status: &'static str,
message: &'static str,
strategy: &'static str,
previous_pid: Option<u32>,
pid: u32,
state_dir: PathBuf,
resource_output_root: PathBuf,
log_path: PathBuf,
socket_path: PathBuf,
}
fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyhow::Result<()> {
let has_explicit_options = options.sync_option_explicit
|| options.output_explicit
|| options.proxy_option_explicit
|| tools_are_non_default(&options.config, &options.env_baseline_config);
if command_name == "reload" && !has_explicit_options && daemon_rpc_available(&options.state_dir)
{
let report = daemon_rpc_call(&options.state_dir, RPC_METHOD_RELOAD, None)?;
print_json_value(options.output_format, &report)?;
return Ok(());
}
let status_file = read_daemon_status_file(&daemon_status_path(&options.state_dir))?;
// 在停止旧后台前读取已保存的代理凭据,供复用型重启还原。
let saved_proxy_url = read_daemon_proxy_secret(&options.state_dir)?;
let status_report = build_daemon_status_report(&options.state_dir)?;
let previous_pid = status_report.pid.filter(|pid| process_exists(*pid));
if previous_pid.is_some() {
if daemon_rpc_available(&options.state_dir)
&& daemon_rpc_call(&options.state_dir, RPC_METHOD_STOP, None).is_ok()
{
if let Some(pid) = previous_pid {
let _ = wait_for_rpc_stop_or_terminate(pid, Duration::from_secs(10))?;
}
let _ = fs::remove_file(daemon_pid_path(&options.state_dir));
let _ = fs::remove_file(daemon_socket_path(&options.state_dir));
} else {
let _ = stop_daemon_inner(&options.state_dir)?;
}
} else if read_pid_file(&daemon_pid_path(&options.state_dir))?.is_some() {
let _ = stop_daemon_inner(&options.state_dir)?;
}
let (state_dir, resource_output_root, localized_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)?,
normalized_abs_path(&options.config.localized_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,
status_file
.localized_output_root
.unwrap_or_else(|| options.config.localized_output_root.clone()),
args,
proxy_url,
"restart_with_existing_command",
)
};
let start = start_daemon_with_args(
state_dir.clone(),
resource_output_root.clone(),
localized_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<()> {
run_sync_command_with_rpc(options, command_name, daemon_rpc_available, daemon_rpc_call)
}
fn run_sync_command_with_rpc(
options: &CliOptions,
command_name: &'static str,
rpc_available: impl Fn(&Path) -> bool,
rpc_call: impl Fn(&Path, &str, Option<serde_json::Value>) -> anyhow::Result<serde_json::Value>,
) -> anyhow::Result<()> {
if let Some(rpc_method) =
sync_command_rpc_method(options, command_name).filter(|_| rpc_available(&options.state_dir))
{
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
let params = if rpc_method == RPC_METHOD_REFRESH {
Some(serde_json::json!({ "force": options.config.force }))
} else {
None
};
let report = rpc_call(&options.state_dir, rpc_method, params)?;
print_json_value(options.output_format, &report)?;
return Ok(());
}
run_sync_command_foreground(options, command_name)
}
fn run_sync_command_foreground(
options: &CliOptions,
command_name: &'static str,
) -> anyhow::Result<()> {
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 resource_index_query_from_options(options: &CliOptions) -> ResourceQuery {
ResourceQuery {
resource_type: options.query_resource_type,
hash: options.query_hash.clone(),
path_pattern: options.query_path_pattern.clone(),
official_release_id: options.query_official_release_id.clone(),
platform: options.query_platform.clone(),
destination: options.query_destination.clone(),
bundle_path: options.query_bundle_path.clone(),
archive_entry: options.query_archive_entry.clone(),
parse_status: options.query_parse_status.clone(),
text_unit_format: options.query_format.clone(),
}
}
fn textunit_query_from_options(options: &CliOptions) -> OfficialTextUnitQuery {
OfficialTextUnitQuery {
destination: options.query_destination.clone(),
path_pattern: options.query_path_pattern.clone(),
archive_entry: options.query_archive_entry.clone(),
path_id: options.query_path_id,
class_id: options.query_class_id,
field_path: options.query_field_path.clone(),
format: options.query_format.clone(),
}
}
fn translation_task_query_from_options(options: &CliOptions) -> OfficialTextUnitTaskQuery {
OfficialTextUnitTaskQuery {
task_id: options.query_task_id.clone(),
official_release_id: options.query_official_release_id.clone(),
destination: options.query_destination.clone(),
path_pattern: options.query_path_pattern.clone(),
archive_entry: options.query_archive_entry.clone(),
status: options.query_task_status.clone(),
parse_status: options.query_parse_status.clone(),
text_unit_format: options.query_format.clone(),
has_reason: options.query_has_reason,
has_failure_reason: options.query_has_failure_reason,
task_status: options.query_worker_status.clone(),
}
}
fn resource_type_rpc_label(resource_type: ResourceType) -> &'static str {
match resource_type {
ResourceType::AssetBundle => "asset_bundle",
ResourceType::Manifest => "manifest",
ResourceType::TableBundle => "table_bundle",
ResourceType::TextAsset => "text_asset",
ResourceType::Media => "media",
ResourceType::Other => "other",
}
}
fn sync_command_rpc_method(options: &CliOptions, command_name: &str) -> Option<&'static str> {
let defaults = OfficialUpdateConfig::default();
let default_daemon_shape = !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.localized_output_root == defaults.localized_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
&& options.config.import_repository == defaults.import_repository
&& options.config.import_cas_root == defaults.import_cas_root
&& options.config.import_resource_repository_path
== defaults.import_resource_repository_path;
if !default_daemon_shape {
return None;
}
match (options.command, command_name) {
(CliCommand::Refresh, "refresh") => Some(RPC_METHOD_REFRESH),
(CliCommand::Repair, "repair") if !options.config.force => Some(RPC_METHOD_RESOURCE_REPAIR),
_ => None,
}
}
#[cfg(test)]
fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bool {
sync_command_rpc_method(options, command_name) == Some(RPC_METHOD_REFRESH)
}
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 let Some(hash) = progress.official_hash.as_ref() {
return format!(
"{}/{} official_hash algorithm={} expected={} actual={} data={} hash={}",
progress.index,
progress.total,
hash.algorithm.as_str(),
hash.expected,
hash.actual,
hash.data_url,
hash.hash_url
);
}
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
);
}
if let Some(verification) = progress.verification.as_ref() {
return format!(
"{}/{} {} bytes={} blake3={} zip_checked={} zip_verified={} {}",
progress.index,
progress.total,
status,
verification.actual_bytes,
verification.actual_blake3,
verification.zip_checked,
verification.zip_structure_verified,
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.status_code.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.waiting_for_official_resources),
);
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("汉化输出目录", &self.localized_output_root);
print_field("汉化发布状态", self.localized_release_status.as_str());
print_path_field("汉化 current", &self.localized_current_path);
print_optional_path_field(
"汉化 published",
self.localized_published_version_path.as_ref(),
);
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("资源变更集", self.resource_change_set_path.as_ref());
print_optional_path_field("Crowdin handoff", self.crowdin_handoff_path.as_ref());
print_optional_path_field("解析缓存", self.parse_cache_path.as_ref());
print_optional_path_field("TextUnit 任务队列", self.textunit_task_queue_path.as_ref());
print_optional_path_field(
"Crowdin TextUnit 队列",
self.crowdin_textunit_queue_path.as_ref(),
);
print_optional_path_field("写入 snapshot", self.snapshot_written.as_ref());
print_optional_path_field(
"启动器引导产物",
self.launcher_bootstrap_artifact_path.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);
if let Some(summary) = self.resource_change_summary.as_ref() {
print_field("新增资源", summary.added_count);
print_field("变更资源", summary.modified_count);
print_field("删除资源", summary.removed_count);
print_field("解析候选", summary.parse_candidate_count);
print_field("Crowdin 候选", summary.translation_candidate_count);
}
if let Some(summary) = self.parse_summary.as_ref() {
print_field("解析缓存条目", summary.cache_entry_count);
print_field("解析成功 bundle", summary.parsed_bundle_count);
print_field("解析复用", summary.skipped_unchanged_count);
print_field("解析不支持", summary.unsupported_count);
print_field("解析失败", summary.failed_count);
print_field("TextAsset", summary.text_asset_count);
print_field("TextUnit", summary.text_unit_count);
print_field("二进制 TextAsset", summary.skipped_binary_text_asset_count);
print_field("TextUnit 诊断", summary.text_unit_error_count);
}
if let Some(summary) = self.textunit_task_summary.as_ref() {
print_field("TextUnit 资源候选", summary.resource_candidate_count);
print_field("TextUnit 解析条目", summary.parse_entry_count);
print_field("TextUnit 任务", summary.queued_task_count);
print_field("增量 TextUnit", summary.text_unit_count);
print_field("TextUnit 无解析", summary.skipped_no_parse_entry_count);
print_field("TextUnit 无文本", summary.skipped_no_text_unit_count);
print_field("TextUnit 解析失败", summary.skipped_parse_failed_count);
print_field("TextUnit 不支持", summary.skipped_unsupported_count);
}
print_field("catalog marker", self.addressables_marker_checked_count);
if !self.unavailable_endpoints.is_empty() {
let unavailable = self
.unavailable_endpoints
.iter()
.map(|endpoint| {
format!(
"{}{} kind={} http={} {}",
endpoint_kind_label_for_human(endpoint.kind),
endpoint
.platform
.map(|platform| format!(" ({})", platform_label(platform)))
.unwrap_or_default(),
endpoint.error_kind,
endpoint
.http_status
.map(|status| status.to_string())
.unwrap_or_else(|| "none".to_string()),
endpoint.url
)
})
.collect::<Vec<_>>();
print_list("不可用官方 endpoint", &unavailable, 8);
}
print_list("变更 endpoint", &self.changed_endpoint_urls, 8);
print_list("计划 URL", &self.download_urls, 8);
Ok(())
}
}
fn endpoint_kind_label_for_human(kind: YostarJpResourceEndpointKind) -> &'static str {
match kind {
YostarJpResourceEndpointKind::TableCatalog => "table_catalog",
YostarJpResourceEndpointKind::TableCatalogHash => "table_catalog_hash",
YostarJpResourceEndpointKind::AddressablesCatalog => "addressables_catalog",
YostarJpResourceEndpointKind::AddressablesCatalogHash => "addressables_catalog_hash",
YostarJpResourceEndpointKind::BundlePackingInfo => "bundle_packing_info",
YostarJpResourceEndpointKind::BundlePackingInfoHash => "bundle_packing_info_hash",
YostarJpResourceEndpointKind::MediaCatalog => "media_catalog",
YostarJpResourceEndpointKind::MediaCatalogHash => "media_catalog_hash",
}
}
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 PatchApplyReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_field("Patch 类型", self.kind.as_str());
print_path_field("源文件", &self.source_path);
print_path_field("Patch 文件", &self.patch_path);
print_path_field("目标文件", &self.target_path);
print_field("源字节", self.source_size);
print_field("Patch 字节", self.patch_size);
print_field("目标字节", self.target_size);
print_field("源 BLAKE3", &self.source_blake3);
print_field("Patch BLAKE3", &self.patch_blake3);
print_field("目标 BLAKE3", &self.target_blake3);
Ok(())
}
}
impl HumanReport for UnityFsPatchReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title(self.message);
print_field("命令", self.command);
print_field("状态", self.status);
print_path_field("源 bundle", &self.bundle_path);
print_field("Serialized 文件", &self.serialized_file_path);
print_field("Path ID", self.path_id);
print_optional_field("字段路径", self.field_path.as_deref());
print_path_field("目标 bundle", &self.target_path);
print_field("源字节", self.source_size);
print_field("替换字节", self.replacement_size);
print_field("目标字节", self.target_size);
print_field("源 BLAKE3", &self.source_blake3);
print_field("替换 BLAKE3", &self.replacement_blake3);
print_field("目标 BLAKE3", &self.target_blake3);
Ok(())
}
}
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.localized_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.localized_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 build_doctor_report(
state_dir: &Path,
config: &OfficialUpdateConfig,
) -> anyhow::Result<DoctorReport> {
let mut checks = vec![
path_check("state_dir", state_dir, "后台状态目录可用"),
path_check("output_root", &config.output_root, "资源输出目录可用"),
path_check(
"localized_output_root",
&config.localized_output_root,
"汉化输出目录可用",
),
safety_check(
"output_root_safety",
validate_output_root(&config.output_root),
"资源输出目录安全边界通过",
),
safety_check(
"localized_output_root_safety",
validate_output_root(&config.localized_output_root),
"汉化输出目录安全边界通过",
),
safety_check(
"state_dir_safety",
validate_runtime_state_dir(state_dir),
"后台状态目录安全边界通过",
),
command_check("curl", &config.curl_command),
proxy_check(&config.curl_proxy),
command_check("unzip", &config.unzip_command),
];
let pid_path = daemon_pid_path(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(state_dir);
let socket_exists = daemon_socket_path_exists(&socket_path).unwrap_or(false);
let socket_available = daemon_rpc_available(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 = 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(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(state_dir);
// ok 与 message 从同一次解析结果派生,避免“ok=false 却提示可解析”的自相矛盾。
let daemon_status_result = read_daemon_status_file(&status_path);
checks.push(DoctorCheck {
name: "daemon_status",
ok: daemon_status_result.is_ok(),
message: match &daemon_status_result {
Ok(Some(_)) => format!("后台状态文件可解析:{}", status_path.display()),
Ok(None) => "后台状态文件尚未创建".to_string(),
Err(error) => format!("后台状态文件无法解析:{}{error}", status_path.display()),
},
});
let healthy = checks.iter().all(|check| check.ok);
Ok(DoctorReport {
command: "doctor",
status: if healthy { "ok" } else { "issues_found" },
message: if healthy {
"运行时诊断通过"
} else {
"运行时诊断发现问题"
},
healthy,
checks,
})
}
fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
let report = build_doctor_report(&options.state_dir, &options.config)?;
let healthy = report.healthy;
print_report(options.output_format, &report)?;
Ok(healthy)
}
fn path_check(name: &'static str, path: &Path, ready_message: &str) -> DoctorCheck {
let (ok, message) = if path.is_dir() {
(true, format!("{ready_message}{}", path.display()))
} else if path.exists() {
(false, format!("路径存在但不是目录:{}", path.display()))
} else {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
(
parent.is_dir(),
if parent.is_dir() {
format!(
"目录尚未创建,但父目录可用,将在首次运行时创建:{}",
path.display()
)
} else {
format!("目录不存在且父目录不可用:{}", path.display())
},
)
};
DoctorCheck { name, ok, message }
}
fn safety_check(name: &'static str, result: Result<(), String>, ok_message: &str) -> DoctorCheck {
match result {
Ok(()) => DoctorCheck {
name,
ok: true,
message: ok_message.to_string(),
},
Err(error) => DoctorCheck {
name,
ok: false,
message: error,
},
}
}
fn command_check(name: &'static str, command: &Path) -> DoctorCheck {
let result = Command::new(command).arg("--version").output();
DoctorCheck {
name,
ok: result.is_ok(),
message: match result {
Ok(output) => format!(
"命令可执行:{}(退出码={:?}",
command.display(),
output.status.code()
),
Err(error) => format!("命令不可执行 {}{error}", command.display()),
},
}
}
fn proxy_check(config: &CurlProxyConfig) -> DoctorCheck {
let resolved = resolve_curl_proxy(config);
// 对解析出的代理 URL(含 auto 模式从环境变量取得的)做 scheme 校验,
// 让 doctor 能在前置阶段暴露拼错的代理配置,而非恒为 ok。
let (ok, message) = match resolved.url.as_deref() {
Some(url) => match validate_proxy_url(url) {
Ok(()) => (true, resolved.human_summary()),
Err(error) => (false, format!("{}{error}", resolved.human_summary())),
},
None => (true, resolved.human_summary()),
};
DoctorCheck {
name: "proxy",
ok,
message,
}
}
#[derive(Debug, Serialize)]
struct CleanStableReport {
command: &'static str,
status: &'static str,
message: &'static str,
output_root: PathBuf,
state_dir: PathBuf,
removed_paths: Vec<PathBuf>,
skipped_paths: Vec<PathBuf>,
}
fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> {
validate_output_root(&options.config.output_root).map_err(anyhow::Error::msg)?;
validate_runtime_state_dir(&options.state_dir).map_err(anyhow::Error::msg)?;
let status = build_daemon_status_report(&options.state_dir)?;
if status.running || status.rpc_available {
return Err(anyhow::anyhow!(
"后台进程正在运行,不能执行 clean-stable;请先执行 stop"
));
}
let mut removed_paths = Vec::new();
let mut skipped_paths = Vec::new();
for root in [&options.config.output_root, &options.state_dir] {
collect_transient_files(root, &mut removed_paths)?;
}
let pid_path = daemon_pid_path(&options.state_dir);
match classify_pid_lock_file(&pid_path)? {
PidLockState::Missing => {}
PidLockState::Active(_) => skipped_paths.push(pid_path),
PidLockState::StalePid(_) | PidLockState::Corrupt => {
fs::remove_file(&pid_path)?;
removed_paths.push(pid_path);
}
}
let socket_path = daemon_socket_path(&options.state_dir);
if daemon_socket_path_exists(&socket_path)? {
if daemon_rpc_available(&options.state_dir) {
skipped_paths.push(socket_path);
} else {
fs::remove_file(&socket_path)?;
removed_paths.push(socket_path);
}
}
let lock_path = options.config.lock_path();
match classify_pid_lock_file(&lock_path)? {
PidLockState::Missing => {}
PidLockState::Active(_) => skipped_paths.push(lock_path),
PidLockState::StalePid(_) | PidLockState::Corrupt => {
fs::remove_file(&lock_path)?;
removed_paths.push(lock_path);
}
}
let control_lock_path = daemon_control_lock_path(&options.state_dir);
match classify_pid_lock_file(&control_lock_path)? {
PidLockState::Missing => {}
PidLockState::Active(pid) => {
return Err(anyhow::anyhow!(
"后台控制命令已被锁定 (locked){}owner_pid={pid} 仍在运行",
control_lock_path.display()
));
}
PidLockState::StalePid(_) | PidLockState::Corrupt => {
fs::remove_file(&control_lock_path)?;
removed_paths.push(control_lock_path);
}
}
// 后台已停止,清除残留的代理凭据文件,不把凭据留在磁盘上。
let proxy_secret_path = daemon_proxy_secret_path(&options.state_dir);
if proxy_secret_path.exists() {
fs::remove_file(&proxy_secret_path)?;
removed_paths.push(proxy_secret_path);
}
// 后台已停止,清理未被版本状态引用的孤儿 staging 目录。
if let Some(state) = read_version_state(&options.config.version_state_path())? {
removed_paths.extend(gc_orphan_staging(&options.config.output_root, &state)?);
}
let report = CleanStableReport {
command: "clean-stable",
status: if skipped_paths.is_empty() {
"cleaned"
} else {
"cleaned_with_skips"
},
message: "已清理断点临时文件、临时状态文件和失效锁/PID;不会删除正式资源",
output_root: options.config.output_root.clone(),
state_dir: options.state_dir.clone(),
removed_paths,
skipped_paths,
};
print_report(options.output_format, &report)?;
Ok(())
}
fn collect_transient_files(root: &Path, removed_paths: &mut Vec<PathBuf>) -> anyhow::Result<()> {
if !root.exists() {
return Ok(());
}
let metadata = fs::symlink_metadata(root)?;
if !metadata.is_dir() {
return Ok(());
}
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
let metadata = fs::symlink_metadata(&path)?;
if metadata.is_dir() {
collect_transient_files(&path, removed_paths)?;
continue;
}
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
if file_name.ends_with(".part") || file_name.ends_with(".tmp") {
fs::remove_file(&path)?;
removed_paths.push(path);
}
}
Ok(())
}
fn read_daemon_status_file(path: &Path) -> anyhow::Result<Option<DaemonStatusFile>> {
let Some(bytes) = read_file_no_symlink(path, "后台状态文件").map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
Ok(Some(serde_json::from_slice(&bytes)?))
}
/// 串行化 `bat-status.json` 的读-改-写。
///
/// watch 循环线程(`update_daemon_status`/`update_daemon_progress`)与 RPC 处理线程
/// `update_daemon_state_only`)都会更新状态文件;单次写虽原子,但“读→改字段→写回”
/// 整体非原子,无此锁会因 last-writer-wins 丢失并发线程的字段更新。
static DAEMON_STATUS_FILE_LOCK: Mutex<()> = Mutex::new(());
fn lock_daemon_status_file() -> std::sync::MutexGuard<'static, ()> {
DAEMON_STATUS_FILE_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner())
}
fn write_daemon_status_file(path: &Path, status: &DaemonStatusFile) -> anyhow::Result<()> {
write_file_atomic(
path,
&serde_json::to_vec_pretty(status)?,
PRIVATE_FILE_MODE,
"后台状态文件",
)
.map_err(anyhow::Error::msg)?;
Ok(())
}
fn update_daemon_status(state_dir: &Path, update: DaemonStatusUpdate<'_>) -> anyhow::Result<()> {
let _guard = lock_daemon_status_file();
let status_path = daemon_status_path(state_dir);
let mut status = read_daemon_status_file(&status_path)?.unwrap_or_else(|| DaemonStatusFile {
version: DAEMON_STATUS_VERSION,
pid: std::process::id(),
state: "started".to_string(),
resource_output_root: OfficialUpdateConfig::default().output_root,
localized_output_root: Some(OfficialUpdateConfig::default().localized_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,
status_code: 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.status_code = match update.state {
"running" => Some(ReleaseFlowStatusCode::OfficialChecking.as_str().to_string()),
"waiting" => Some(
ReleaseFlowStatusCode::OfficialWaitingForResources
.as_str()
.to_string(),
),
"error" => Some(ReleaseFlowStatusCode::OfficialFailed.as_str().to_string()),
"sleeping" => update
.last_update_status
.as_deref()
.map(ReleaseFlowStatusCode::from_update_status)
.map(|code| code.as_str().to_string()),
_ => None,
};
status.last_update_status = update.last_update_status;
status.last_error = update.last_error;
status.next_retry_seconds = update.next_retry_seconds;
if update.last_success_unix_seconds.is_some() {
status.last_success_unix_seconds = update.last_success_unix_seconds;
}
status.next_check_unix_seconds = update.next_check_unix_seconds;
if update.state != "running" {
status.current_stage = None;
status.current_message = None;
status.download_progress = None;
}
status.pending_scheduled_force = update.pending_scheduled_force;
status.next_forced_refresh_unix_seconds =
system_time_to_unix_seconds(update.next_forced_refresh_at);
write_daemon_status_file(&status_path, &status)
}
fn update_daemon_progress(state_dir: &Path, event: &OfficialUpdateProgress) -> anyhow::Result<()> {
let _guard = lock_daemon_status_file();
let status_path = daemon_status_path(state_dir);
let Some(mut status) = read_daemon_status_file(&status_path)? else {
return Ok(());
};
status.pid = std::process::id();
status.updated_unix_seconds = unix_seconds_now();
status.current_stage = Some(event.stage.to_string());
status.status_code = Some(event.status_code.as_str().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,
verification: event.download_verification.clone(),
official_hash: event.official_hash_verification.clone(),
}),
_ if event.stage != "download" => None,
_ => status.download_progress,
};
write_daemon_status_file(&status_path, &status)
}
fn update_daemon_state_only(state_dir: &Path, state: &str) -> anyhow::Result<()> {
let _guard = lock_daemon_status_file();
let status_path = daemon_status_path(state_dir);
let Some(mut status) = read_daemon_status_file(&status_path)? else {
return Ok(());
};
status.pid = std::process::id();
status.state = state.to_string();
status.updated_unix_seconds = unix_seconds_now();
status.next_retry_seconds = None;
write_daemon_status_file(&status_path, &status)
}
fn read_pid_file(path: &Path) -> anyhow::Result<Option<u32>> {
let Some(bytes) = read_file_no_symlink(path, "后台 PID 文件").map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
let contents = String::from_utf8(bytes)?;
Ok(contents.trim().parse::<u32>().ok().filter(|pid| *pid > 0))
}
fn daemon_pid_path(state_dir: &Path) -> PathBuf {
state_dir.join(DAEMON_PID_FILE)
}
fn daemon_status_path(state_dir: &Path) -> PathBuf {
state_dir.join(DAEMON_STATUS_FILE)
}
fn daemon_log_path(state_dir: &Path) -> PathBuf {
state_dir.join(DAEMON_LOG_FILE)
}
fn daemon_structured_log_path(state_dir: &Path) -> PathBuf {
state_dir.join(DAEMON_STRUCTURED_LOG_FILE)
}
fn rotated_structured_log_path(path: &Path, index: usize) -> PathBuf {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(DAEMON_STRUCTURED_LOG_FILE);
path.with_file_name(format!("{file_name}.{index}"))
}
fn rotated_structured_log_paths(path: &Path) -> Vec<PathBuf> {
(1..=STRUCTURED_LOG_ROTATE_KEEP)
.map(|index| rotated_structured_log_path(path, index))
.filter(|path| path_exists_no_follow(path).unwrap_or(false))
.collect()
}
fn daemon_socket_path(state_dir: &Path) -> PathBuf {
state_dir.join(DAEMON_SOCKET_FILE)
}
fn daemon_control_lock_path(state_dir: &Path) -> PathBuf {
state_dir.join(DAEMON_CONTROL_LOCK_FILE)
}
fn daemon_proxy_secret_path(state_dir: &Path) -> PathBuf {
state_dir.join(DAEMON_PROXY_SECRET_FILE)
}
/// 返回 URL 型代理的凭据字符串;auto/disabled 模式返回 None。
fn curl_proxy_url(config: &CurlProxyConfig) -> Option<String> {
match config.mode() {
CurlProxyMode::Url(url) => Some(url.clone()),
CurlProxyMode::Auto | CurlProxyMode::Disabled => None,
}
}
/// 将代理凭据写入专用 0600 文件,供 restart/reload 复用;该文件永不进入状态输出。
fn write_daemon_proxy_secret(state_dir: &Path, url: &str) -> anyhow::Result<()> {
write_file_atomic(
&daemon_proxy_secret_path(state_dir),
url.as_bytes(),
PRIVATE_FILE_MODE,
"代理凭据文件",
)
.map_err(anyhow::Error::msg)
}
/// 删除代理凭据文件(best-effort);无代理或拆除后台时调用。
fn clear_daemon_proxy_secret(state_dir: &Path) {
let _ = fs::remove_file(daemon_proxy_secret_path(state_dir));
}
/// 读取代理凭据文件;缺失或为空返回 None。
fn read_daemon_proxy_secret(state_dir: &Path) -> anyhow::Result<Option<String>> {
let Some(bytes) = read_file_no_symlink(&daemon_proxy_secret_path(state_dir), "代理凭据文件")
.map_err(anyhow::Error::msg)?
else {
return Ok(None);
};
let url =
String::from_utf8(bytes).map_err(|_| anyhow::anyhow!("代理凭据文件不是有效 UTF-8"))?;
let trimmed = url.trim();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(trimmed.to_string()))
}
}
fn active_official_resource_root(output_root: &Path) -> anyhow::Result<PathBuf> {
let current_path = output_root.join(OFFICIAL_CURRENT_LINK);
let metadata = match fs::symlink_metadata(&current_path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(output_root.to_path_buf());
}
Err(error) => return Err(error.into()),
};
if !metadata.file_type().is_symlink() {
return Err(anyhow::anyhow!(
"current 已存在但不是 symlink{}",
current_path.display()
));
}
let target = fs::read_link(&current_path)?;
let target = if target.is_absolute() {
target
} else {
output_root.join(target)
};
lexical_absolute(&target).map_err(anyhow::Error::msg)
}
fn daemon_child_args(options: &CliOptions) -> Vec<String> {
let mut args = Vec::new();
let config = &options.config;
if config.auto_discover {
args.push("--auto-discover".to_string());
}
if let Some(source) = config.server_info_source.as_ref() {
match source {
OfficialServerInfoSource::LocalPath(path) => {
args.push("--server-info-path".to_string());
args.push(path.to_string_lossy().to_string());
}
OfficialServerInfoSource::OfficialFile(name) => {
args.push("--server-info-file".to_string());
args.push(name.clone());
}
OfficialServerInfoSource::OfficialUrl(url) => {
args.push("--server-info-url".to_string());
args.push(url.clone());
}
}
}
if let Some(connection_group) = config.connection_group.as_ref() {
args.push("--connection-group".to_string());
args.push(connection_group.clone());
}
if let Some(app_version) = config.app_version.as_ref() {
args.push("--app-version".to_string());
args.push(app_version.clone());
}
args.push("--launcher-version".to_string());
args.push(config.launcher_version.clone());
if let Some(platforms) = config.platforms.as_ref() {
args.push("--platforms".to_string());
args.push(
platforms
.iter()
.map(|platform| match platform {
PatchPlatform::Windows => "Windows",
PatchPlatform::Android => "Android",
})
.collect::<Vec<_>>()
.join(","),
);
}
args.push("--output".to_string());
args.push(config.output_root.to_string_lossy().to_string());
args.push("--localized-output".to_string());
args.push(config.localized_output_root.to_string_lossy().to_string());
if config.import_repository {
args.push("--import-repository".to_string());
} else {
args.push("--no-import-repository".to_string());
}
if let Some(cas_root) = config.import_cas_root.as_ref() {
args.push("--import-cas-root".to_string());
args.push(cas_root.to_string_lossy().to_string());
}
if let Some(repository_path) = config.import_resource_repository_path.as_ref() {
args.push("--import-resource-db".to_string());
args.push(repository_path.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());
args.push("--download-concurrency".to_string());
args.push(config.download_concurrency.to_string());
match config.curl_proxy.mode() {
CurlProxyMode::Auto if options.proxy_option_explicit => {
args.push("--proxy".to_string());
args.push("auto".to_string());
}
CurlProxyMode::Auto => {}
CurlProxyMode::Disabled => {
args.push("--no-proxy".to_string());
}
CurlProxyMode::Url(_) => {
// 凭据经 PROXY_URL_ENV_VAR 环境变量下传子进程;此处只放不含凭据的 flag,
// 避免代理 URL 进入子进程 argv/proc/<pid>/cmdline)与状态文件 command 字段。
args.push(PROXY_FROM_ENV_FLAG.to_string());
}
}
args.push("--unzip".to_string());
args.push(config.unzip_command.to_string_lossy().to_string());
if config.force {
args.push("--force".to_string());
}
if config.audit_local {
args.push("--audit-local".to_string());
} else {
args.push("--no-audit-local".to_string());
}
if config.repair {
args.push("--repair".to_string());
} else {
args.push("--no-repair".to_string());
}
args.push("--state-dir".to_string());
args.push(options.state_dir.to_string_lossy().to_string());
args.push("--daemon-child".to_string());
args.push("--watch".to_string());
args.push("--interval".to_string());
args.push(format_duration_arg(options.interval));
args.push("--error-retry".to_string());
args.push(format_duration_arg(options.error_retry_interval));
if options.quiet_up_to_date {
args.push("--quiet-up-to-date".to_string());
} else {
args.push("--no-quiet-up-to-date".to_string());
}
if options.progress {
args.push("--progress".to_string());
} else {
args.push("--no-progress".to_string());
}
if options.output_format == OutputFormat::Json {
args.push("--json".to_string());
}
if options.banner {
args.push("--banner".to_string());
} else {
args.push("--no-banner".to_string());
}
args
}
fn format_duration_arg(duration: Duration) -> String {
let millis = duration.as_millis();
if millis.is_multiple_of(3_600_000) {
format!("{}h", millis / 3_600_000)
} else if millis.is_multiple_of(60_000) {
format!("{}m", millis / 60_000)
} else if millis.is_multiple_of(1_000) {
format!("{}s", millis / 1_000)
} else {
format!("{millis}ms")
}
}
fn unix_seconds_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn unix_seconds_after(duration: Duration) -> u64 {
unix_seconds_now().saturating_add(duration.as_secs())
}
fn system_time_to_unix_seconds(time: SystemTime) -> Option<u64> {
time.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
}
#[cfg(unix)]
fn configure_daemon_command(command: &mut Command) {
use std::os::unix::process::CommandExt;
unsafe {
command.pre_exec(|| {
if libc::setsid() < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
});
}
}
#[cfg(not(unix))]
fn configure_daemon_command(_command: &mut Command) {}
#[cfg(unix)]
fn process_exists(pid: u32) -> bool {
let Ok(pid) = libc::pid_t::try_from(pid) else {
return false;
};
if pid <= 0 {
return false;
}
unsafe {
libc::kill(pid, 0) == 0
|| std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
}
// 非 Unix 平台无法用 kill(pid, 0) 探测进程存活;生产链路只支持 Linux。
// 这里保守返回 true(假定进程仍存活),避免误把可能仍在运行的 daemon 的
// PID/锁文件当作 stale 而回收——宁可要求手动清理,也不冒重复启动的风险。
#[cfg(not(unix))]
fn process_exists(_pid: u32) -> bool {
true
}
#[cfg(unix)]
fn terminate_process(pid: u32) -> anyhow::Result<()> {
let pid = libc::pid_t::try_from(pid)?;
if pid <= 0 {
return Err(anyhow::anyhow!("无效的后台进程 pid"));
}
let rc = unsafe { libc::kill(pid, libc::SIGTERM) };
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error().into())
}
}
#[cfg(not(unix))]
fn terminate_process(_pid: u32) -> anyhow::Result<()> {
Err(anyhow::anyhow!("stop 目前只支持 Unix/Linux 平台"))
}
fn wait_for_process_exit(pid: u32, timeout: Duration) -> bool {
let started_at = Instant::now();
while started_at.elapsed() < timeout {
if !process_exists(pid) {
return true;
}
thread::sleep(Duration::from_millis(100));
}
!process_exists(pid)
}
fn wait_for_rpc_stop_or_terminate(pid: u32, timeout: Duration) -> anyhow::Result<bool> {
if wait_for_process_exit(pid, timeout) {
return Ok(false);
}
terminate_process(pid)?;
if !wait_for_process_exit(pid, Duration::from_secs(5)) {
return Err(anyhow::anyhow!("后台进程 pid={pid} 在 SIGTERM 后仍未停止"));
}
Ok(true)
}
fn next_forced_refresh_at_or_after(now: SystemTime) -> SystemTime {
forced_refresh_time(now, ForcedRefreshBoundary::AtOrAfter)
}
fn next_forced_refresh_after(now: SystemTime) -> SystemTime {
forced_refresh_time(now, ForcedRefreshBoundary::After)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ForcedRefreshBoundary {
AtOrAfter,
After,
}
fn forced_refresh_time(now: SystemTime, boundary: ForcedRefreshBoundary) -> SystemTime {
let local_seconds = beijing_local_seconds_since_epoch(now);
let local_day = local_seconds / SECONDS_PER_DAY;
let local_second_of_day = local_seconds % SECONDS_PER_DAY;
for scheduled_second in DAILY_FORCED_REFRESH_LOCAL_SECONDS {
let matches_boundary = match boundary {
ForcedRefreshBoundary::AtOrAfter => scheduled_second >= local_second_of_day,
ForcedRefreshBoundary::After => scheduled_second > local_second_of_day,
};
if matches_boundary {
return system_time_from_beijing_local_seconds(
local_day
.saturating_mul(SECONDS_PER_DAY)
.saturating_add(scheduled_second),
);
}
}
system_time_from_beijing_local_seconds(
local_day
.saturating_add(1)
.saturating_mul(SECONDS_PER_DAY)
.saturating_add(DAILY_FORCED_REFRESH_LOCAL_SECONDS[0]),
)
}
fn beijing_local_seconds_since_epoch(time: SystemTime) -> u64 {
time.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.saturating_add(BEIJING_UTC_OFFSET_SECONDS)
}
fn system_time_from_beijing_local_seconds(local_seconds: u64) -> SystemTime {
UNIX_EPOCH + Duration::from_secs(local_seconds.saturating_sub(BEIJING_UTC_OFFSET_SECONDS))
}
fn duration_until(deadline: SystemTime, now: SystemTime) -> Duration {
deadline.duration_since(now).unwrap_or_default()
}
#[derive(Debug, Clone)]
struct ProgressLogger {
enabled: bool,
started_at: Instant,
structured: Option<RotatingStructuredLogger>,
}
impl ProgressLogger {
fn new(enabled: bool) -> Self {
Self {
enabled,
started_at: Instant::now(),
structured: None,
}
}
fn attach_structured_log(&mut self, path: PathBuf) {
self.structured = Some(RotatingStructuredLogger::new(
path,
STRUCTURED_LOG_MAX_BYTES,
STRUCTURED_LOG_ROTATE_KEEP,
));
}
fn log(&mut self, event: OfficialUpdateProgress) {
self.log_event(&event);
}
fn log_text(&mut self, stage: &str, message: impl AsRef<str>) {
self.log_text_inner(stage, message.as_ref());
}
fn log_event(&mut self, event: &OfficialUpdateProgress) {
if self.enabled {
eprintln!(
"[+{} 信息] [{}] {}",
format_duration(self.started_at.elapsed()),
localized_stage(event.stage),
event.message
);
}
if let Some(structured) = self.structured.as_mut() {
let _ = structured.write_event(self.started_at.elapsed(), event);
}
}
fn log_text_inner(&mut self, stage: &str, message: &str) {
if !self.enabled {
if let Some(structured) = self.structured.as_mut() {
let event = OfficialUpdateProgress::new(stage_to_static(stage), message);
let _ = structured.write_event(self.started_at.elapsed(), &event);
}
return;
}
eprintln!(
"[+{} 信息] [{}] {}",
format_duration(self.started_at.elapsed()),
localized_stage(stage),
message
);
if let Some(structured) = self.structured.as_mut() {
let event = OfficialUpdateProgress::new(stage_to_static(stage), message);
let _ = structured.write_event(self.started_at.elapsed(), &event);
}
}
}
#[derive(Debug, Clone)]
struct RotatingStructuredLogger {
path: PathBuf,
max_bytes: u64,
keep: usize,
}
impl RotatingStructuredLogger {
fn new(path: PathBuf, max_bytes: u64, keep: usize) -> Self {
Self {
path,
max_bytes,
keep,
}
}
fn write_event(
&mut self,
elapsed: Duration,
event: &OfficialUpdateProgress,
) -> anyhow::Result<()> {
let payload = serde_json::json!({
"timestamp_unix_seconds": unix_seconds_now(),
"elapsed_ms": elapsed.as_millis(),
"level": "info",
"stage": event.stage,
"stage_label": localized_stage(event.stage),
"status_code": event.status_code.as_str(),
"status_phase": event.status_code.phase(),
"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,
"verification": event.download_verification,
"official_hash": event.official_hash_verification,
})),
});
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",
"parse" => "parse",
"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" => "发布",
"parse" => "解析",
"resource" => "资源",
"finish" => "完成",
"watch" => "常驻",
"daemon" => "后台",
_ => stage,
}
}
fn is_locked_error(message: &str) -> bool {
message.contains("locked") || message.contains("锁定")
}
fn print_startup_banner() {
eprintln!("{STARTUP_BANNER}");
}
fn should_print_status(status: OfficialUpdateStatus, quiet_up_to_date: bool) -> bool {
!(quiet_up_to_date && status == OfficialUpdateStatus::UpToDate)
}
/// `.env` 配置文件名(位于 bat 二进制所在目录)。
const ENV_FILE_NAME: &str = ".env";
/// 设为 `1` 时完全跳过 `.env` 的生成与加载(测试与特殊部署场景用)。
const SKIP_ENV_FILE_VAR: &str = "BAT_SKIP_ENV_FILE";
/// 首次启动释放的 `.env` 配置模板。
const ENV_TEMPLATE: &str = r#"# BlueArchive Toolkit 配置文件(bat 首次启动自动生成)
#
# 直接运行 `bat`(无参数)时会按本文件配置启动。
# 优先级:命令行参数 > 进程环境变量 > 本文件 > 内置默认值。
# 布尔值支持 1/0/true/false/yes/no/on/off;井号开头为注释。
# 设 BAT_SKIP_ENV_FILE=1 可让 bat 完全忽略本文件。
# ---- 基本配置 ----
# 官方原版资源发布根目录(默认 ./bat-resources,相对当前工作目录)
BAT_OUTPUT=./bat-resources
# 汉化产物输出根目录(默认 ./bat-localized,与官方原版资源分离)
BAT_LOCALIZED_OUTPUT=./bat-localized
# 启用官方 release 导入 CAS + ResourceRepository;默认关闭。
BAT_IMPORT_REPOSITORY=0
# 官方资源 CAS 目录;未设置时默认 <BAT_OUTPUT>/.cas
BAT_IMPORT_CAS_ROOT=
# 官方资源 SQLite 索引;未设置时默认 <BAT_OUTPUT>/resources.sqlite
BAT_IMPORT_RESOURCE_DB=
# 自动发现 app-version / connection-group / server-info(无参启动建议保持 1
BAT_AUTO_DISCOVER=1
# 后台状态目录(bat.sock / 日志 / 任务历史等;默认 /tmp/bat-pid
#BAT_STATE_DIR=/tmp/bat-pid
# 启动即进入常驻模式:watch(前台常驻)或 daemon(后台自托管)。
# 只对无子命令的 `bat` 生效;同时为 1 时 daemon 优先。
#BAT_WATCH=0
#BAT_DAEMON=0
# 正常检查间隔与失败重试间隔(秒)
#BAT_INTERVAL_SECONDS=3600
#BAT_ERROR_RETRY_SECONDS=60
# ---- 网络 ----
# 显式代理 URL(支持 http/https/socks4/socks4a/socks5/socks5h)。
# 不设则自动检测 HTTPS_PROXY / ALL_PROXY / HTTP_PROXY(也可写在本文件里)。
#BAT_PROXY=http://127.0.0.1:7897
# 设为 1 时强制直连(忽略一切代理配置)
#BAT_NO_PROXY=0
# ---- 同步参数(通常保持自动发现,无需手动指定)----
#BAT_APP_VERSION=
#BAT_CONNECTION_GROUP=
#BAT_LAUNCHER_VERSION=
# 逗号分隔:windows,android
#BAT_PLATFORMS=windows,android
#BAT_CURL=curl
# 最大并发下载数(默认 8;范围 1..=256)
#BAT_DOWNLOAD_CONCURRENCY=8
#BAT_UNZIP=unzip
# ---- 输出 ----
# 设为 1 时输出机器可读 JSON(默认人类可读)
#BAT_JSON=0
# 远端与本地一致时是否静默(watch/daemon 模式默认 1
#BAT_QUIET_UP_TO_DATE=
# ---- Redis(预留,当前未接入)----
# 任务历史当前持久化在 <BAT_STATE_DIR>/bat-tasks.json
# Redis 任务后端落地后以下配置才会生效。
#BAT_REDIS_URL=redis://127.0.0.1:6379
#BAT_REDIS_PASSWORD=
"#;
/// `.env` 引导:首次启动时在二进制所在目录释放配置模板,之后每次启动把其中的
/// 键加载为进程环境变量(不覆盖已存在的环境变量,保持"环境变量 > .env"优先级)。
///
/// 任何失败只在 stderr 警告、不中断启动——`.env` 是便利层,不是启动硬依赖。
fn bootstrap_env_file() {
if env::var(SKIP_ENV_FILE_VAR).map(|value| value == "1") == Ok(true) {
return;
}
let Ok(exe_path) = env::current_exe() else {
return;
};
let Some(exe_dir) = exe_path.parent() else {
return;
};
let path = exe_dir.join(ENV_FILE_NAME);
if !path.exists() {
match write_env_template(&path) {
Ok(()) => eprintln!(
"已生成配置模板 {}(编辑其中的 BAT_* 配置后,直接运行 `bat` 即可按 .env 启动)",
path.display()
),
Err(error) => {
eprintln!("警告:生成 .env 配置模板失败 {}{error}", path.display());
return;
}
}
}
match fs::read_to_string(&path) {
Ok(content) => apply_env_file(&content),
Err(error) => eprintln!("警告:读取 .env 失败 {}{error}", path.display()),
}
}
/// 以 `create_new` 原子创建模板文件,避免并发启动时互相覆盖;unix 下限制 `0600`
/// 权限(`.env` 可能保存代理凭据等敏感配置)。
fn write_env_template(path: &Path) -> std::io::Result<()> {
let mut open_options = OpenOptions::new();
open_options.write(true).create_new(true);
#[cfg(unix)]
open_options.mode(PRIVATE_FILE_MODE);
let mut file = open_options.open(path)?;
file.write_all(ENV_TEMPLATE.as_bytes())
}
/// 解析 `.env` 内容,把进程环境里尚不存在的键设为环境变量。
fn apply_env_file(content: &str) {
for (line_number, raw_line) in content.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = parse_env_line(line) else {
eprintln!(
"警告:.env 第 {} 行无法解析,已忽略:{raw_line}",
line_number + 1
);
continue;
};
if env::var_os(&key).is_none() {
env::set_var(&key, value);
}
}
}
/// 解析单行 `KEY=VALUE`。key 须为 `[A-Za-z_][A-Za-z0-9_]*`;值两侧的成对
/// 单/双引号会剥除。不支持 `export` 前缀和多行值。
fn parse_env_line(line: &str) -> Option<(String, String)> {
let (key, value) = line.split_once('=')?;
let key = key.trim();
let valid_key = !key.is_empty()
&& key.chars().enumerate().all(|(index, character)| {
character == '_'
|| character.is_ascii_alphabetic()
|| (index > 0 && character.is_ascii_digit())
});
if !valid_key {
return None;
}
let mut value = value.trim();
if value.len() >= 2 {
let bytes = value.as_bytes();
let quoted = (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'');
if quoted {
value = &value[1..value.len() - 1];
}
}
Some((key.to_string(), value.to_string()))
}
/// `.env`/环境变量提供的运行模式开关(延迟到命令确定后应用;值型配置
/// 由 [`apply_bat_env_overrides`] 直接写入 options)。
struct EnvModeOverrides {
watch: bool,
daemon: bool,
}
/// 把 `BAT_*` 环境变量作为配置默认值写入 options。
///
/// 不标记任何 `*_explicit`(命令行参数在其后解析、总是覆盖);非法值报错
/// 而非静默忽略,保证配置问题可诊断。
fn apply_bat_env_overrides(
options: &mut CliOptions,
env_lookup: &impl Fn(&str) -> Option<String>,
) -> anyhow::Result<EnvModeOverrides> {
fn parse_env_bool(key: &str, value: &str) -> anyhow::Result<bool> {
match value.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
other => Err(anyhow::anyhow!(
"环境变量 {key} 的布尔值无效:{other}(支持 1/0/true/false/yes/no/on/off"
)),
}
}
fn parse_env_seconds(key: &str, value: &str) -> anyhow::Result<Duration> {
let seconds = value
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("环境变量 {key} 的秒数无效:{error}"))?;
Ok(Duration::from_secs(seconds))
}
// 空值视为未设置:模板里保留 `BAT_XXX=` 形式的空行不产生副作用。
let value = |key: &str| {
env_lookup(key)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
};
if let Some(v) = value("BAT_OUTPUT") {
options.config.output_root = PathBuf::from(v);
}
if let Some(v) = value("BAT_LOCALIZED_OUTPUT") {
options.config.localized_output_root = PathBuf::from(v);
}
if let Some(v) = value("BAT_IMPORT_REPOSITORY") {
options.config.import_repository = parse_env_bool("BAT_IMPORT_REPOSITORY", &v)?;
}
if let Some(v) = value("BAT_IMPORT_CAS_ROOT") {
options.config.import_cas_root = Some(PathBuf::from(v));
}
if let Some(v) = value("BAT_IMPORT_RESOURCE_DB") {
options.config.import_resource_repository_path = Some(PathBuf::from(v));
}
if let Some(v) = value("BAT_STATE_DIR") {
options.state_dir = PathBuf::from(v);
}
if let Some(v) = value("BAT_AUTO_DISCOVER") {
options.config.auto_discover = parse_env_bool("BAT_AUTO_DISCOVER", &v)?;
}
if let Some(v) = value("BAT_APP_VERSION") {
options.config.app_version = Some(v);
}
if let Some(v) = value("BAT_CONNECTION_GROUP") {
options.config.connection_group = Some(v);
}
if let Some(v) = value("BAT_LAUNCHER_VERSION") {
options.config.launcher_version = v;
}
if let Some(v) = value("BAT_PLATFORMS") {
options.config.platforms = Some(parse_platforms(&v).map_err(anyhow::Error::msg)?);
}
if let Some(v) = value("BAT_CURL") {
options.config.curl_command = PathBuf::from(v);
}
if let Some(v) = value("BAT_DOWNLOAD_CONCURRENCY") {
options.config.download_concurrency =
parse_download_concurrency(&v, "环境变量 BAT_DOWNLOAD_CONCURRENCY")?;
}
if let Some(v) = value("BAT_UNZIP") {
options.config.unzip_command = PathBuf::from(v);
}
if let Some(v) = value("BAT_PROXY") {
options.config.curl_proxy = parse_proxy_config(&v)?;
}
if let Some(v) = value("BAT_NO_PROXY") {
if parse_env_bool("BAT_NO_PROXY", &v)? {
options.config.curl_proxy = CurlProxyConfig::disabled();
}
}
if let Some(v) = value("BAT_INTERVAL_SECONDS") {
options.interval = parse_env_seconds("BAT_INTERVAL_SECONDS", &v)?;
}
if let Some(v) = value("BAT_ERROR_RETRY_SECONDS") {
options.error_retry_interval = parse_env_seconds("BAT_ERROR_RETRY_SECONDS", &v)?;
}
if let Some(v) = value("BAT_JSON") {
if parse_env_bool("BAT_JSON", &v)? {
options.output_format = OutputFormat::Json;
}
}
if let Some(v) = value("BAT_QUIET_UP_TO_DATE") {
options.quiet_up_to_date = parse_env_bool("BAT_QUIET_UP_TO_DATE", &v)?;
options.quiet_up_to_date_explicit = true;
}
let watch = match value("BAT_WATCH") {
Some(v) => parse_env_bool("BAT_WATCH", &v)?,
None => false,
};
let daemon = match value("BAT_DAEMON") {
Some(v) => parse_env_bool("BAT_DAEMON", &v)?,
None => false,
};
Ok(EnvModeOverrides { watch, daemon })
}
fn parse_args() -> anyhow::Result<CliOptions> {
parse_args_with_env(env::args(), |key| env::var(key).ok())
}
/// 测试入口:不读环境变量,解析结果只由参数决定。
#[cfg(test)]
fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result<CliOptions> {
parse_args_with_env(raw_args, |_| None)
}
fn parse_args_with_env(
raw_args: impl IntoIterator<Item = String>,
env_lookup: impl Fn(&str) -> Option<String>,
) -> anyhow::Result<CliOptions> {
let mut args = raw_args.into_iter();
let binary = args.next().unwrap_or_else(|| "bat".to_string());
let mut options = CliOptions::default();
// `BAT_*` 环境变量(含 .env 加载的)先作为默认值写入,不标记 explicit;
// 命令行参数随后解析,逐字段覆盖。工具/代理的"非默认"判断以本基线为准,
// 保证 status/stop/logs 在 .env 存在时不误判为显式传了同步参数。
let env_modes = apply_bat_env_overrides(&mut options, &env_lookup)?;
options.env_baseline_config = options.config.clone();
let mut mode_flag_from_cli = false;
while let Some(flag) = args.next() {
match flag.as_str() {
"status" => {
ensure_command_not_set(options.command, "status")?;
options.command = CliCommand::Status;
}
"stop" => {
ensure_command_not_set(options.command, "stop")?;
options.command = CliCommand::Stop;
}
"restart" => {
ensure_command_not_set(options.command, "restart")?;
options.command = CliCommand::Restart;
}
"reload" => {
ensure_command_not_set(options.command, "reload")?;
options.command = CliCommand::Reload;
}
"refresh" => {
ensure_command_not_set(options.command, "refresh")?;
options.command = CliCommand::Refresh;
}
"verify" => {
ensure_command_not_set(options.command, "verify")?;
options.command = CliCommand::Verify;
}
"repair" => {
ensure_command_not_set(options.command, "repair")?;
options.command = CliCommand::Repair;
}
"parse-status" => {
ensure_command_not_set(options.command, "parse-status")?;
options.command = CliCommand::ParseStatus;
}
"parse-text-units" => {
ensure_command_not_set(options.command, "parse-text-units")?;
options.command = CliCommand::ParseTextUnits;
}
"parse-errors" => {
ensure_command_not_set(options.command, "parse-errors")?;
options.command = CliCommand::ParseErrors;
}
"translation-tasks" => {
ensure_command_not_set(options.command, "translation-tasks")?;
options.command = CliCommand::TranslationTasks;
}
"translation-handoff" => {
ensure_command_not_set(options.command, "translation-handoff")?;
options.command = CliCommand::TranslationHandoff;
}
"localized-status" => {
ensure_command_not_set(options.command, "localized-status")?;
options.command = CliCommand::LocalizedStatus;
}
"resource-index" => {
ensure_command_not_set(options.command, "resource-index")?;
options.command = CliCommand::ResourceIndex;
}
"patch-apply" => {
ensure_command_not_set(options.command, "patch-apply")?;
options.command = CliCommand::PatchApply;
}
"unityfs-patch-text-asset" => {
ensure_command_not_set(options.command, "unityfs-patch-text-asset")?;
options.command = CliCommand::UnityFsPatchTextAsset;
}
"unityfs-patch-string-field" => {
ensure_command_not_set(options.command, "unityfs-patch-string-field")?;
options.command = CliCommand::UnityFsPatchStringField;
}
"unityfs-patch-field" => {
ensure_command_not_set(options.command, "unityfs-patch-field")?;
options.command = CliCommand::UnityFsPatchField;
}
"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;
}
"--localized-output" => {
options.config.localized_output_root =
PathBuf::from(next_option_value(&mut args, &flag)?);
options.output_explicit = true;
}
"--import-repository" => {
options.config.import_repository = true;
options.sync_option_explicit = true;
}
"--no-import-repository" => {
options.config.import_repository = false;
options.sync_option_explicit = true;
}
"--import-cas-root" => {
options.config.import_cas_root =
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
options.sync_option_explicit = true;
}
"--import-resource-db" | "--resource-db" => {
options.config.import_resource_repository_path =
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
options.sync_option_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)?);
}
"--download-concurrency" => {
options.config.download_concurrency =
parse_download_concurrency(&next_option_value(&mut args, &flag)?, &flag)?;
options.sync_option_explicit = true;
}
"--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;
mode_flag_from_cli = true;
}
"--daemon" => {
options.daemon = true;
options.sync_option_explicit = true;
mode_flag_from_cli = true;
}
"--daemon-child" => {
options.daemon_child = true;
options.watch = true;
options.sync_option_explicit = true;
mode_flag_from_cli = true;
}
"--interval" => {
options.interval = parse_duration(&next_option_value(&mut args, &flag)?)?;
options.sync_option_explicit = true;
}
"--interval-seconds" => {
let seconds = next_option_value(&mut args, &flag)?
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("{flag} 的秒数无效:{error}"))?;
options.interval = Duration::from_secs(seconds);
options.sync_option_explicit = true;
}
"--error-retry" => {
options.error_retry_interval =
parse_duration(&next_option_value(&mut args, &flag)?)?;
options.sync_option_explicit = true;
}
"--error-retry-seconds" => {
let seconds = next_option_value(&mut args, &flag)?
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("{flag} 的秒数无效:{error}"))?;
options.error_retry_interval = Duration::from_secs(seconds);
options.sync_option_explicit = true;
}
"--quiet-up-to-date" => {
options.quiet_up_to_date = true;
options.quiet_up_to_date_explicit = true;
options.sync_option_explicit = true;
}
"--no-quiet-up-to-date" => {
options.quiet_up_to_date = false;
options.quiet_up_to_date_explicit = true;
options.sync_option_explicit = true;
}
"--progress" => {
options.progress = true;
options.banner = true;
}
"--no-progress" => {
options.progress = false;
options.banner = false;
}
"--json" => {
options.output_format = OutputFormat::Json;
}
"--human" => {
options.output_format = OutputFormat::Human;
}
"--banner" => {
options.banner = true;
}
"--no-banner" => {
options.banner = false;
}
"--tail" => {
options.tail_lines = next_option_value(&mut args, &flag)?
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("{flag} 的行数无效:{error}"))?;
if options.tail_lines == 0 {
return Err(anyhow::anyhow!("--tail 必须大于 0"));
}
}
"--offset" => {
options.query_offset = next_option_value(&mut args, &flag)?
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?;
options.query_option_explicit = true;
}
"--limit" => {
options.query_limit = next_option_value(&mut args, &flag)?
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?;
if options.query_limit == 0 || options.query_limit > 1000 {
return Err(anyhow::anyhow!("--limit 必须在 1..=1000 范围内"));
}
options.query_option_explicit = true;
}
"--task-id" => {
options.query_task_id = Some(next_option_value(&mut args, &flag)?);
options.query_option_explicit = true;
}
"--resource-type" => {
options.query_resource_type = Some(parse_resource_type_param(&next_option_value(
&mut args, &flag,
)?)?);
options.query_option_explicit = true;
}
"--hash" => {
options.query_hash = Some(next_option_value(&mut args, &flag)?);
options.query_option_explicit = true;
}
"--path-pattern" => {
options.query_path_pattern = Some(next_option_value(&mut args, &flag)?);
options.query_option_explicit = true;
}
"--release-id" | "--official-release-id" => {
options.query_official_release_id = Some(next_option_value(&mut args, &flag)?);
options.query_option_explicit = true;
}
"--platform" => {
options.query_platform = Some(next_option_value(&mut args, &flag)?);
options.query_option_explicit = true;
}
"--destination" => {
options.query_destination = Some(next_option_value(&mut args, &flag)?);
options.query_option_explicit = true;
}
"--bundle-path" => {
options.query_bundle_path = Some(next_option_value(&mut args, &flag)?);
options.query_option_explicit = true;
}
"--archive-entry" => {
options.query_archive_entry = Some(next_option_value(&mut args, &flag)?);
options.query_option_explicit = true;
}
"--task-status" => {
options.query_task_status = Some(next_option_value(&mut args, &flag)?);
options.query_option_explicit = true;
}
"--worker-status" => {
options.query_worker_status = Some(next_option_value(&mut args, &flag)?);
options.query_option_explicit = true;
}
"--parse-status" => {
options.query_parse_status = Some(next_option_value(&mut args, &flag)?);
options.query_option_explicit = true;
}
"--path-id" => {
options.query_path_id = Some(
next_option_value(&mut args, &flag)?
.parse::<i64>()
.map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?,
);
options.query_option_explicit = true;
}
"--class-id" => {
options.query_class_id = Some(
next_option_value(&mut args, &flag)?
.parse::<i32>()
.map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?,
);
options.query_option_explicit = true;
}
"--field-path" => {
if matches!(
options.command,
CliCommand::UnityFsPatchStringField | CliCommand::UnityFsPatchField
) {
options.unityfs_field_path = Some(next_option_value(&mut args, &flag)?);
options.write_patch_option_explicit = true;
} else {
options.query_field_path = Some(next_option_value(&mut args, &flag)?);
options.query_option_explicit = true;
}
}
"--format" => {
options.query_format = Some(next_option_value(&mut args, &flag)?);
options.query_option_explicit = true;
}
"--has-reason" => {
options.query_has_reason = Some(true);
options.query_option_explicit = true;
}
"--no-reason" => {
options.query_has_reason = Some(false);
options.query_option_explicit = true;
}
"--has-failure-reason" => {
options.query_has_failure_reason = Some(true);
options.query_option_explicit = true;
}
"--no-failure-reason" => {
options.query_has_failure_reason = Some(false);
options.query_option_explicit = true;
}
"--patch-kind" => {
options.patch_kind = Some(parse_patch_apply_kind(&next_option_value(
&mut args, &flag,
)?)?);
options.write_patch_option_explicit = true;
}
"--source-file" => {
options.patch_source_path =
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
options.write_patch_option_explicit = true;
}
"--patch-file" => {
options.patch_patch_path =
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
options.write_patch_option_explicit = true;
}
"--target-file" => {
options.patch_target_path =
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
options.write_patch_option_explicit = true;
}
"--bundle-file" => {
options.unityfs_bundle_path =
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
options.write_patch_option_explicit = true;
}
"--serialized-file" => {
options.unityfs_serialized_file_path = Some(next_option_value(&mut args, &flag)?);
options.write_patch_option_explicit = true;
}
"--object-path-id" => {
options.unityfs_path_id = Some(
next_option_value(&mut args, &flag)?
.parse::<i64>()
.map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?,
);
options.write_patch_option_explicit = true;
}
"--string-field-path" => {
options.unityfs_field_path = Some(next_option_value(&mut args, &flag)?);
options.write_patch_option_explicit = true;
}
"--replacement-file" => {
options.unityfs_replacement_path =
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
options.write_patch_option_explicit = true;
}
"--replacement-text" => {
options.unityfs_replacement_text = Some(next_option_value(&mut args, &flag)?);
options.write_patch_option_explicit = true;
}
"--expected-name" => {
options.unityfs_expected_name = Some(next_option_value(&mut args, &flag)?);
options.write_patch_option_explicit = true;
}
"--expected-value" => {
options.unityfs_expected_value = Some(next_option_value(&mut args, &flag)?);
options.write_patch_option_explicit = true;
}
"--replacement-json" => {
options.unityfs_replacement_value = Some(parse_replacement_value_json(
&next_option_value(&mut args, &flag)?,
&flag,
)?);
options.write_patch_option_explicit = true;
}
"--expected-json" => {
options.unityfs_expected_semantic_value = Some(parse_replacement_value_json(
&next_option_value(&mut args, &flag)?,
&flag,
)?);
options.write_patch_option_explicit = true;
}
"--help" | "-h" => {
print_usage(&binary);
std::process::exit(0);
}
_ => return Err(anyhow::anyhow!("未知参数:{flag}")),
}
}
// `BAT_WATCH` / `BAT_DAEMON` 只影响无子命令的 Run(无参启动场景);
// 命令行显式选择了运行模式或 dry-run 时让位(命令行优先于 .env),
// status/verify 等子命令不受其影响。daemon 优先于 watchdaemon 自带 watch)。
if matches!(options.command, CliCommand::Run) && !mode_flag_from_cli && !options.config.dry_run
{
if env_modes.daemon {
options.daemon = true;
} else if env_modes.watch {
options.watch = true;
}
}
if !is_write_patch_command(options.command) && options.write_patch_option_explicit {
return Err(anyhow::anyhow!(
"写入 patch 参数只适用于 patch-apply、unityfs-patch-text-asset、unityfs-patch-string-field 或 unityfs-patch-field"
));
}
match options.command {
CliCommand::Status | CliCommand::Stop | CliCommand::Logs => {
if options.sync_option_explicit
|| options.output_explicit
|| options.proxy_option_explicit
|| tools_are_non_default(&options.config, &options.env_baseline_config)
{
return Err(anyhow::anyhow!(
"status/stop/logs 只读取 --state-dir;资源同步参数和输出目录无关"
));
}
options.progress = false;
options.banner = false;
}
CliCommand::ParseStatus
| CliCommand::ParseTextUnits
| CliCommand::ParseErrors
| CliCommand::TranslationTasks
| CliCommand::TranslationHandoff
| CliCommand::LocalizedStatus
| CliCommand::ResourceIndex => {
validate_readonly_query_options(&options)?;
options.progress = false;
options.banner = false;
}
CliCommand::PatchApply
| CliCommand::UnityFsPatchTextAsset
| CliCommand::UnityFsPatchStringField
| CliCommand::UnityFsPatchField => {
if options.output_explicit {
return Err(anyhow::anyhow!(
"写入 patch 命令不使用 --output;请用 --target-file 指定目标文件"
));
}
if options.sync_option_explicit
|| options.proxy_option_explicit
|| tools_are_non_default(&options.config, &options.env_baseline_config)
{
return Err(anyhow::anyhow!(
"写入 patch 命令只接受文件 patch 参数、--state-dir、--json/--human"
));
}
validate_write_patch_options(&options)?;
options.progress = false;
options.banner = false;
}
CliCommand::Doctor | CliCommand::CleanStable => {
if options.sync_option_explicit {
return Err(anyhow::anyhow!(
"doctor/clean-stable 只接受 --output、--state-dir、--curl、--proxy 和 --unzip 等诊断参数"
));
}
options.progress = false;
options.banner = false;
}
CliCommand::Refresh | CliCommand::Verify | CliCommand::Repair => {
if !options.config.auto_discover
&& options.config.server_info_source.is_none()
&& options.config.connection_group.is_none()
&& options.config.app_version.is_none()
{
options.config.auto_discover = true;
}
if matches!(options.command, CliCommand::Verify) {
options.config.dry_run = true;
options.config.plan = true;
options.config.audit_local = true;
options.config.repair = false;
}
}
CliCommand::Restart | CliCommand::Reload => {
if (options.sync_option_explicit
|| options.output_explicit
|| options.proxy_option_explicit
|| tools_are_non_default(&options.config, &options.env_baseline_config))
&& !options.config.auto_discover
&& options.config.server_info_source.is_none()
&& options.config.connection_group.is_none()
&& options.config.app_version.is_none()
{
options.config.auto_discover = true;
}
}
CliCommand::Run => {}
}
if options.daemon && options.watch {
return Err(anyhow::anyhow!(
"--daemon 会自动启动 watch 模式,不要同时传 --watch"
));
}
if (options.watch || options.daemon || options.daemon_child) && options.config.dry_run {
return Err(anyhow::anyhow!(
"watch/daemon 模式不能和 --dry-run 同时使用"
));
}
if options.interval.is_zero() {
return Err(anyhow::anyhow!("watch 检查间隔必须大于 0"));
}
if options.error_retry_interval.is_zero() {
return Err(anyhow::anyhow!("watch 失败重试间隔必须大于 0"));
}
if (options.watch || options.daemon || options.daemon_child)
&& !options.quiet_up_to_date_explicit
{
options.quiet_up_to_date = true;
}
if matches!(options.command, CliCommand::Verify) {
if options.config.force {
return Err(anyhow::anyhow!(
"verify 只验证本地资源,不能和 --force 同时使用"
));
}
if !options.config.repair && !options.config.audit_local {
return Err(anyhow::anyhow!(
"verify 必须启用本地审计;不要同时传 --no-repair --no-audit-local"
));
}
}
if matches!(options.command, CliCommand::Restart | CliCommand::Reload)
&& (options.config.force || options.config.dry_run)
{
return Err(anyhow::anyhow!(
"restart/reload 不能和 --force 或 --dry-run 同时使用"
));
}
if matches!(options.command, CliCommand::Logs) && options.tail_lines == 0 {
return Err(anyhow::anyhow!("logs 的 --tail 必须大于 0"));
}
Ok(options)
}
fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()> {
if command == CliCommand::Run {
Ok(())
} else {
Err(anyhow::anyhow!("不支持同时指定多个命令;意外命令:{next}"))
}
}
/// 判断 curl/代理/unzip 是否偏离基线。基线是环境变量(含 .env)应用后的
/// 配置快照,因此只有命令行显式传入才算"非默认"。
fn tools_are_non_default(config: &OfficialUpdateConfig, baseline: &OfficialUpdateConfig) -> bool {
config.curl_command != baseline.curl_command
|| config.curl_proxy != baseline.curl_proxy
|| config.unzip_command != baseline.unzip_command
}
fn parse_download_concurrency(value: &str, source: &str) -> anyhow::Result<usize> {
let parsed = value
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("{source} 无效:{error}"))?;
if !(MIN_DOWNLOAD_CONCURRENCY..=MAX_DOWNLOAD_CONCURRENCY).contains(&parsed) {
return Err(anyhow::anyhow!(
"{source} 必须在 {MIN_DOWNLOAD_CONCURRENCY}..={MAX_DOWNLOAD_CONCURRENCY} 范围内"
));
}
Ok(parsed)
}
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!(" parse-status Show current official parse-cache status");
eprintln!(" parse-text-units Query current official TextUnit detail index");
eprintln!(" parse-errors Query current official parse/extraction diagnostics");
eprintln!(" translation-tasks Query current offline TextUnit translation task status");
eprintln!(" translation-handoff Query current translation job/unit/provider handoff");
eprintln!(" localized-status Show localized release status for current official release");
eprintln!(" resource-index Query CAS + ResourceRepository index");
eprintln!(" patch-apply Apply a Binary/JSON/Text patch file");
eprintln!(" unityfs-patch-text-asset Patch one UnityFS TextAsset object");
eprintln!(" unityfs-patch-string-field Patch one UnityFS TypeTree string field");
eprintln!(" unityfs-patch-field Patch one UnityFS TypeTree field with semantic JSON");
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> Official resource publish root (default: ./bat-resources)"
);
eprintln!(
" --localized-output <DIR> Localized output root (default: ./bat-localized)"
);
eprintln!(
" --import-repository Import verified release into CAS + ResourceRepository"
);
eprintln!(" --no-import-repository Disable CAS + ResourceRepository import");
eprintln!(" --import-cas-root <DIR> CAS root for official release imports");
eprintln!(" --import-resource-db <PATH> SQLite ResourceRepository path");
eprintln!(" --snapshot <PATH> Override snapshot path (default: <output>/current/official-sync-snapshot.json)");
eprintln!(" --curl <PATH> curl executable (default: curl)");
eprintln!(
" --download-concurrency <N> Bounded parallel downloads (default: 8, range 1..=256)"
);
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!("Read-only queries:");
eprintln!(" --offset <N> Query offset for resource-index/parse-text-units/parse-errors/translation-tasks");
eprintln!(" --limit <N> Query limit for resource-index/parse-text-units/parse-errors/translation-tasks (1..=1000)");
eprintln!(" --task-id <ID> Filter translation-tasks by stable task ID");
eprintln!(" --resource-type <TYPE> asset_bundle, manifest, table_bundle, text_asset, media, other");
eprintln!(" --hash <HASH> Filter resource-index by full CAS hash");
eprintln!(
" --path-pattern <GLOB> Filter resource-index or parse detail by path pattern"
);
eprintln!(" --release-id <ID> Filter resource-index or translation-tasks by official release ID");
eprintln!(" --platform <NAME> Filter resource-index by metadata platform");
eprintln!(" --destination <PATH> Filter resource-index, parse detail, or translation-tasks by official destination");
eprintln!(" --bundle-path <PATH> Filter resource-index by metadata bundle path");
eprintln!(" --archive-entry <PATH> Filter resource-index, parse detail, or translation-tasks by ZIP/archive entry");
eprintln!(" --task-status <STATUS> Filter translation-tasks by task status");
eprintln!(
" --worker-status <STATUS> Filter translation-tasks by provider worker status"
);
eprintln!(" --parse-status <STATUS> Filter resource-index or translation-tasks by parse status");
eprintln!(" --path-id <ID> Filter parse detail by Unity object path ID");
eprintln!(" --class-id <ID> Filter parse detail by Unity class ID");
eprintln!(" --field-path <PATH> Filter parse detail, or TypeTree field path after UnityFS field patch commands");
eprintln!(" --format <NAME> Filter resource-index, parse text units, or translation-tasks by payload format");
eprintln!(
" --has-reason | --no-reason Filter translation-tasks by diagnostic reason presence"
);
eprintln!(
" --has-failure-reason | --no-failure-reason Filter translation-tasks by provider failure reason"
);
eprintln!();
eprintln!("Write patch:");
eprintln!(" --patch-kind <binary|json|text> Patch type for patch-apply");
eprintln!(" --source-file <PATH> Source file for patch-apply");
eprintln!(" --patch-file <PATH> Patch JSON file for patch-apply");
eprintln!(" --bundle-file <PATH> Source UnityFS bundle file");
eprintln!(" --serialized-file <PATH> Serialized file path inside UnityFS");
eprintln!(" --object-path-id <ID> Unity object path ID for UnityFS patch");
eprintln!(
" --string-field-path <PATH> Deprecated alias for UnityFS TypeTree field path"
);
eprintln!(" --replacement-file <PATH> Replacement bytes or UTF-8 string file");
eprintln!(" --replacement-text <TEXT> Inline replacement text for string-field patch");
eprintln!(
" --replacement-json <JSON> Semantic replacement, e.g. signed/enum/bit_field JSON"
);
eprintln!(" --expected-name <NAME> Expected TextAsset name");
eprintln!(" --expected-value <TEXT> Expected source string value");
eprintln!(" --expected-json <JSON> Optional expected semantic source value");
eprintln!(" --target-file <PATH> Target output file written atomically");
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!(
" official resource output: ./bat-resources (current -> versions/<id>, .staging/<id>)"
);
eprintln!(" localized output: ./bat-localized (separate patch/export target)");
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)]
#[path = "app_tests.rs"]
mod tests;