From 9a5b3ba39bea7fd8ce9215662b75a020e2bf5518 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Mon, 3 Aug 2026 00:43:11 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8B=86=E5=88=86=20bat=20=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture/README.md | 8 +- .../architecture/official-resource-backend.md | 6 +- docs/guides/official-resource-test-pull.md | 2 +- infrastructure/src/bin/bat/app.rs | 7106 +++++++++ infrastructure/src/bin/bat/app_tests.rs | 3672 +++++ infrastructure/src/bin/bat/patch_commands.rs | 199 + infrastructure/src/bin/bat/readonly_query.rs | 271 + infrastructure/src/bin/bat/task_registry.rs | 587 + .../src/bin/bat/translation_query.rs | 232 + infrastructure/src/bin/bat_official_sync.rs | 12043 +--------------- 10 files changed, 12083 insertions(+), 12043 deletions(-) create mode 100644 infrastructure/src/bin/bat/app.rs create mode 100644 infrastructure/src/bin/bat/app_tests.rs create mode 100644 infrastructure/src/bin/bat/patch_commands.rs create mode 100644 infrastructure/src/bin/bat/readonly_query.rs create mode 100644 infrastructure/src/bin/bat/task_registry.rs create mode 100644 infrastructure/src/bin/bat/translation_query.rs diff --git a/docs/architecture/README.md b/docs/architecture/README.md index d49b4e1..e87ea7e 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -8,7 +8,13 @@ BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建 当前已经可用的官方资源入口包括: -- `infrastructure/src/bin/bat_official_sync.rs`:Linux 官方资源同步正式入口,构建为 `bat`,支持 one-shot、`--watch`、`--daemon`、`status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor` 和 `clean-stable`。 +- `infrastructure/src/bin/bat_official_sync.rs`:Linux 官方资源同步薄入口,构建为 `bat`;控制面组合与实现位于 `infrastructure/src/bin/bat/`,支持 one-shot、`--watch`、`--daemon`、`status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor` 和 `clean-stable`。 +- `infrastructure/src/bin/bat/app.rs`:CLI/env、daemon/watch、RPC dispatch、状态输出与顶层流程组合。 +- `infrastructure/src/bin/bat/task_registry.rs`:任务注册表、任务持久化、取消和 daemon worker。 +- `infrastructure/src/bin/bat/readonly_query.rs`:parse/resource/translation/localized 只读查询及 RPC 选择。 +- `infrastructure/src/bin/bat/translation_query.rs`:翻译任务与 handoff 查询、worker 状态更新。 +- `infrastructure/src/bin/bat/patch_commands.rs`:文件 patch 与 UnityFS 写入命令参数校验和执行。 +- `infrastructure/src/bin/bat/app_tests.rs`:控制面回归测试,避免测试代码继续堆积在入口实现中。 - `infrastructure/src/official_update.rs`:官方自动更新核心服务,负责 auto-discover、snapshot、marker diff、本地 audit/repair。 - `infrastructure/examples/official_pull_plan.rs`:开发/审计用 pull plan 入口。 - `infrastructure/examples/official_update_check.rs`:历史/开发入口,生产优先使用 `bat`。 diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index 5f36cc1..f97ff4c 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -246,7 +246,11 @@ release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrat 对应实现主要在: - `infrastructure/src/official_update.rs` -- `infrastructure/src/bin/bat_official_sync.rs` +- `infrastructure/src/bin/bat_official_sync.rs`(薄入口) +- `infrastructure/src/bin/bat/app.rs`(控制面组合) +- `infrastructure/src/bin/bat/task_registry.rs`(任务注册表、持久化和 worker) +- `infrastructure/src/bin/bat/readonly_query.rs`、`translation_query.rs`(只读查询) +- `infrastructure/src/bin/bat/patch_commands.rs`(patch 命令) - `infrastructure/examples/official_update_check.rs`(历史/开发入口) ## 4. 官方 bootstrap 与用户流程 diff --git a/docs/guides/official-resource-test-pull.md b/docs/guides/official-resource-test-pull.md index 35ab2df..811931f 100644 --- a/docs/guides/official-resource-test-pull.md +++ b/docs/guides/official-resource-test-pull.md @@ -356,7 +356,7 @@ make official-smoke - `infrastructure/examples/official_launcher_bootstrap.rs` - `infrastructure/examples/official_pull_plan.rs` -- `infrastructure/src/bin/bat_official_sync.rs` +- `infrastructure/src/bin/bat_official_sync.rs`(薄入口;控制面实现位于同目录 `bat/`) - `infrastructure/examples/official_update_check.rs`(历史/开发入口;生产优先使用 `bat`) - `adapters/examples/yostar_jp_client_bootstrap.rs` - `adapters/examples/yostar_jp_discovery.rs` diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs new file mode 100644 index 0000000..cb113b5 --- /dev/null +++ b/infrastructure/src/bin/bat/app.rs @@ -0,0 +1,7106 @@ +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//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 { + 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, +} + +#[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, + query_resource_type: Option, + query_hash: Option, + query_path_pattern: Option, + query_official_release_id: Option, + query_platform: Option, + query_destination: Option, + query_bundle_path: Option, + query_archive_entry: Option, + query_task_status: Option, + query_worker_status: Option, + query_parse_status: Option, + query_path_id: Option, + query_class_id: Option, + query_field_path: Option, + query_format: Option, + query_has_reason: Option, + query_has_failure_reason: Option, + query_option_explicit: bool, + patch_kind: Option, + patch_source_path: Option, + patch_patch_path: Option, + patch_target_path: Option, + unityfs_bundle_path: Option, + unityfs_serialized_file_path: Option, + unityfs_path_id: Option, + unityfs_field_path: Option, + unityfs_replacement_path: Option, + unityfs_replacement_text: Option, + unityfs_expected_name: Option, + unityfs_expected_value: Option, + unityfs_replacement_value: Option, + unityfs_expected_semantic_value: Option, + 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::(); + 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, + state_dir: PathBuf, + log_path: PathBuf, + #[serde(default)] + structured_log_path: Option, + started_unix_seconds: u64, + updated_unix_seconds: u64, + #[serde(default)] + last_success_unix_seconds: Option, + #[serde(default)] + next_check_unix_seconds: Option, + last_update_status: Option, + last_error: Option, + next_retry_seconds: Option, + #[serde(default)] + current_stage: Option, + #[serde(default)] + status_code: Option, + #[serde(default)] + current_message: Option, + #[serde(default)] + download_progress: Option, + pending_scheduled_force: bool, + next_forced_refresh_unix_seconds: Option, + command: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct DaemonDownloadProgress { + index: usize, + total: usize, + url: String, + status: Option, + bytes: Option, + transferred_bytes: Option, + #[serde(default)] + failure_kind: Option, + #[serde(default)] + failure_http_status: Option, + #[serde(default)] + failure_retryable: Option, + #[serde(default)] + failure_attempts: Option, + #[serde(default)] + quarantined: Option, + #[serde(default)] + verification: Option, + #[serde(default)] + official_hash: Option, +} + +#[derive(Debug)] +struct DaemonStatusUpdate<'a> { + state: &'a str, + last_update_status: Option, + last_error: Option, + next_retry_seconds: Option, + last_success_unix_seconds: Option, + next_check_unix_seconds: Option, + pending_scheduled_force: bool, + next_forced_refresh_at: SystemTime, +} + +type DaemonControl = Arc<(Mutex, Condvar)>; + +#[derive(Debug, Default)] +struct DaemonControlState { + stop_requested: bool, + refresh_requested: bool, + force_refresh_requested: bool, + reload_requested: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DaemonWake { + Timeout, + Stop, + Refresh { force: bool }, + Reload, +} + +#[derive(Debug, Deserialize)] +struct JsonRpcRequest { + #[allow(dead_code)] + jsonrpc: Option, + id: Option, + method: String, + params: Option, +} + +#[derive(Debug, Serialize)] +struct JsonRpcResponse { + jsonrpc: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct JsonRpcError { + code: i32, + message: String, +} + +#[derive(Debug, Deserialize)] +struct JsonRpcClientResponse { + #[allow(dead_code)] + jsonrpc: String, + #[allow(dead_code)] + id: Option, + result: Option, + error: Option, +} + +#[derive(Debug, Serialize)] +struct DaemonRpcAck { + command: &'static str, + status: &'static str, + message: &'static str, + state_dir: PathBuf, + socket_path: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + controller_pid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + force: Option, +} + +// 规范方法名采用国际惯例的 `.`。`bat.*` 保留为向后兼容别名。 +const RPC_METHOD_STATUS: &str = "daemon.status"; +const RPC_METHOD_STOP: &str = "daemon.stop"; +const RPC_METHOD_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-stable:CLI 侧按进程生命周期处理; + // 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(skip_serializing_if = "Option::is_none")] + error: Option, + request_id: String, +} + +/// 生成进程内唯一的 request_id(`req--`)。 +fn next_request_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(1); + format!( + "req-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + ) +} + +fn rpc_envelope_ok( + request_id: String, + status: &'static str, + data: serde_json::Value, +) -> RpcEnvelope { + RpcEnvelope { + ok: true, + status, + data: Some(data), + error: None, + request_id, + } +} + +fn rpc_envelope_error(request_id: String, error: ApiError) -> RpcEnvelope { + RpcEnvelope { + ok: false, + status: "error", + data: None, + error: Some(error), + request_id, + } +} + +/// 把内部 `anyhow::Result` 转成 envelope;成功为 ok,失败归为内部错误码。 +fn rpc_envelope_from_result( + request_id: String, + location: &'static str, + result: anyhow::Result, +) -> RpcEnvelope { + match result { + Ok(data) => rpc_envelope_ok(request_id, "ok", data), + Err(error) => rpc_envelope_error( + request_id, + ApiError::new(ErrorCode::INTERNAL, location, error.to_string()), + ), + } +} + +fn record_daemon_status( + enabled: bool, + state_dir: &Path, + logger: &mut ProgressLogger, + update: DaemonStatusUpdate<'_>, +) { + if !enabled { + return; + } + + if let Err(error) = update_daemon_status(state_dir, update) { + logger.log_text("daemon", format!("更新后台状态失败:{error}")); + } +} + +fn record_daemon_progress( + enabled: bool, + state_dir: &Path, + logger: &mut ProgressLogger, + event: &OfficialUpdateProgress, +) { + if !enabled { + return; + } + + if let Err(error) = update_daemon_progress(state_dir, event) { + logger.log_text("daemon", format!("更新后台进度失败:{error}")); + } +} + +#[derive(Debug)] +struct DaemonRpcServer { + socket_path: PathBuf, +} + +impl Drop for DaemonRpcServer { + fn drop(&mut self) { + let _ = fs::remove_file(&self.socket_path); + } +} + +#[derive(Debug)] +struct DaemonControlLock { + path: PathBuf, + pid: u32, +} + +impl DaemonControlLock { + fn acquire(state_dir: &Path) -> anyhow::Result { + validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?; + fs::create_dir_all(state_dir)?; + let path = daemon_control_lock_path(state_dir); + let pid = std::process::id(); + for attempt in 0..=1 { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(PRIVATE_FILE_MODE); + match options.open(&path) { + Ok(mut file) => { + file.write_all(pid.to_string().as_bytes())?; + return Ok(Self { path, pid }); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + if attempt == 0 && remove_recoverable_pid_lock(&path)? { + continue; + } + return Err(anyhow::anyhow!( + "后台控制命令已被锁定 (locked):{};{};如确认没有 bat 控制命令正在运行,可执行 clean-stable 清理", + path.display(), + describe_pid_lock_owner(&path)? + )); + } + Err(error) => { + return Err(anyhow::anyhow!( + "获取后台控制锁失败 {}:{error}", + path.display() + )); + } + } + } + + Err(anyhow::anyhow!("获取后台控制锁失败 {}", path.display())) + } +} + +impl Drop for DaemonControlLock { + fn drop(&mut self) { + let expected = self.pid.to_string(); + if fs::symlink_metadata(&self.path) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false) + { + return; + } + if fs::read_to_string(&self.path) + .map(|contents| contents.trim() == expected) + .unwrap_or(false) + { + let _ = fs::remove_file(&self.path); + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PidLockState { + Missing, + Active(u32), + StalePid(u32), + Corrupt, +} + +fn classify_pid_lock_file(path: &Path) -> anyhow::Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(PidLockState::Missing); + } + Err(error) => return Err(error.into()), + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Ok(PidLockState::Corrupt); + } + let contents = fs::read_to_string(path)?; + let Some(pid) = parse_pid_value(&contents) else { + return Ok(PidLockState::Corrupt); + }; + if process_exists(pid) { + Ok(PidLockState::Active(pid)) + } else { + Ok(PidLockState::StalePid(pid)) + } +} + +fn remove_recoverable_pid_lock(path: &Path) -> anyhow::Result { + match classify_pid_lock_file(path)? { + PidLockState::Missing => Ok(false), + PidLockState::Active(_) => Ok(false), + PidLockState::StalePid(_) | PidLockState::Corrupt => { + fs::remove_file(path)?; + Ok(true) + } + } +} + +fn describe_pid_lock_owner(path: &Path) -> anyhow::Result { + Ok(match classify_pid_lock_file(path)? { + PidLockState::Missing => "锁文件已不存在".to_string(), + PidLockState::Active(pid) => format!("owner_pid={pid} 仍在运行"), + PidLockState::StalePid(pid) => format!("owner_pid={pid} 已失效"), + PidLockState::Corrupt => "锁文件内容不是有效 PID".to_string(), + }) +} + +fn parse_pid_value(contents: &str) -> Option { + contents.trim().parse::().ok().filter(|pid| *pid > 0) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DaemonResourceConflict { + pid: Option, + daemon_root: Option, + target_root: PathBuf, +} + +fn assert_no_live_daemon_output_conflict( + options: &CliOptions, + command_name: &str, +) -> anyhow::Result<()> { + if options.daemon || options.daemon_child || options.config.dry_run { + return Ok(()); + } + + let Some(conflict) = + live_daemon_resource_conflict(&options.state_dir, &options.config.output_root)? + else { + return Ok(()); + }; + + let daemon_root = conflict + .daemon_root + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "未知".to_string()); + let pid = conflict + .pid + .map(|pid| pid.to_string()) + .unwrap_or_else(|| "未知".to_string()); + Err(anyhow::anyhow!( + "后台同步进程正在管理同一资源目录 (locked):command={command_name} pid={pid} daemon_output={} target_output={};请通过后台 refresh/reload 控制刷新,或先执行 stop 再手动写入资源", + daemon_root, + conflict.target_root.display() + )) +} + +fn live_daemon_resource_conflict( + state_dir: &Path, + output_root: &Path, +) -> anyhow::Result> { + let pid_path = daemon_pid_path(state_dir); + let status_path = daemon_status_path(state_dir); + let pid_from_file = read_pid_file(&pid_path)?; + let rpc_available = daemon_rpc_available(state_dir); + let pid_file_live = pid_from_file.map(process_exists).unwrap_or(false); + let status_file = match read_daemon_status_file(&status_path) { + Ok(status_file) => status_file, + Err(error) if pid_file_live || rpc_available => { + return Err(anyhow::anyhow!( + "后台同步进程可能仍在运行,但状态文件不可解析 (locked):{}:{error};请先执行 status/stop 或 clean-stable 恢复状态目录", + status_path.display() + )); + } + Err(_) => return Ok(None), + }; + let status_pid = status_file.as_ref().map(|status| status.pid); + let pid = pid_from_file.or(status_pid); + let live = pid_file_live || status_pid.map(process_exists).unwrap_or(false) || rpc_available; + if !live { + return Ok(None); + } + + let target_root = normalized_abs_path(output_root)?; + let Some(daemon_root) = status_file + .as_ref() + .map(|status| status.resource_output_root.clone()) + else { + return Ok(Some(DaemonResourceConflict { + pid, + daemon_root: None, + target_root, + })); + }; + + let normalized_daemon_root = normalized_abs_path(&daemon_root)?; + if normalized_daemon_root == target_root { + Ok(Some(DaemonResourceConflict { + pid, + daemon_root: Some(normalized_daemon_root), + target_root, + })) + } else { + Ok(None) + } +} + +fn normalized_abs_path(path: &Path) -> anyhow::Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir()?.join(path) + }; + Ok(fs::canonicalize(&absolute).unwrap_or_else(|_| lexically_normalize_path(&absolute))) +} + +fn lexically_normalize_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + if !normalized.pop() { + normalized.push(component.as_os_str()); + } + } + _ => normalized.push(component.as_os_str()), + } + } + if normalized.as_os_str().is_empty() { + PathBuf::from(".") + } else { + normalized + } +} + +#[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 { + if state.stop_requested { + return Some(DaemonWake::Stop); + } + if state.reload_requested { + state.reload_requested = false; + state.refresh_requested = false; + state.force_refresh_requested = false; + return Some(DaemonWake::Reload); + } + if state.refresh_requested { + state.refresh_requested = false; + let force = state.force_refresh_requested; + state.force_refresh_requested = false; + return Some(DaemonWake::Refresh { force }); + } + None +} + +#[cfg(unix)] +fn start_daemon_rpc_server( + state_dir: &Path, + control: DaemonControl, + tasks: DaemonTaskContext, +) -> anyhow::Result { + validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?; + fs::create_dir_all(state_dir)?; + let socket_path = daemon_socket_path(state_dir); + if daemon_socket_path_exists(&socket_path)? { + ensure_daemon_socket_not_symlink(&socket_path)?; + match UnixStream::connect(&socket_path) { + Ok(_) => { + return Err(anyhow::anyhow!( + "后台 RPC socket 已被占用:{}", + socket_path.display() + )); + } + Err(_) => { + let _ = fs::remove_file(&socket_path); + } + } + } + + let listener = UnixListener::bind(&socket_path)?; + let server_state_dir = state_dir.to_path_buf(); + thread::Builder::new() + .name("bat-daemon-rpc".to_string()) + .spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(stream) => { + let state_dir = server_state_dir.clone(); + let control = Arc::clone(&control); + let tasks = tasks.clone(); + let _ = thread::Builder::new() + .name("bat-daemon-rpc-client".to_string()) + .spawn(move || { + handle_daemon_rpc_client(stream, state_dir, control, tasks) + }); + } + Err(error) => { + eprintln!("[daemon] RPC socket accept 失败:{error}"); + break; + } + } + } + })?; + + Ok(DaemonRpcServer { socket_path }) +} + +#[cfg(not(unix))] +fn start_daemon_rpc_server( + _state_dir: &Path, + _control: DaemonControl, + _tasks: DaemonTaskContext, +) -> anyhow::Result { + Err(anyhow::anyhow!("daemon RPC 目前只支持 Unix/Linux 平台")) +} + +#[cfg(unix)] +fn handle_daemon_rpc_client( + mut stream: UnixStream, + state_dir: PathBuf, + control: DaemonControl, + tasks: DaemonTaskContext, +) { + let Ok(reader_stream) = stream.try_clone() else { + return; + }; + let mut reader = BufReader::new(reader_stream); + let mut line = String::new(); + loop { + line.clear(); + let bytes_read = match reader.read_line(&mut line) { + Ok(bytes_read) => bytes_read, + Err(error) => { + eprintln!("[daemon] RPC socket 读取失败:{error}"); + return; + } + }; + if bytes_read == 0 { + return; + } + if line.trim().is_empty() { + continue; + } + + let mut notify_stop_after_response = false; + let response = match serde_json::from_str::(&line) { + Ok(request) => { + notify_stop_after_response = 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::( + 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(¶ms) + .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::( + 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(¶ms) + .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::( + 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(¶ms) + .and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)), + ) + } + RPC_METHOD_UNITYFS_PATCH_FIELD => { + let params = match rpc_struct_params::( + 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(¶ms) + .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, +) -> 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, Option)> { + let status_file = read_daemon_status_file(&daemon_status_path(state_dir))?; + let version_state = status_file + .as_ref() + .map(|status| { + status + .resource_output_root + .join("official-version-state.json") + }) + .and_then(|path| read_version_state(&path).ok().flatten()); + Ok((status_file, version_state)) +} + +fn 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 { + 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::().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 { + 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 { + let (status_file, version_state) = read_daemon_resource_state(state_dir)?; + let Some(state) = version_state else { + return Ok(serde_json::json!({ "available": false })); + }; + Ok(serde_json::json!({ + "available": true, + "resource_output_root": status_file.map(|status| status.resource_output_root), + "current": state.current_completed_version, + "in_progress": state.in_progress_version, + "previous": state.previous_available_version, + "failed": state.failed_versions, + "updated_unix_seconds": state.updated_unix_seconds, + })) +} + +/// `catalog.diff`:当前已发布 snapshot 相对上一个可用版本的差异。 +/// +/// 没有上一个版本(或其 snapshot 不可读,见 `previous_snapshot_missing`)时 +/// 按首次观察处理(`base_delta.is_initial = true`)。 +fn build_catalog_diff_report(state_dir: &Path) -> anyhow::Result { + let (_, version_state) = read_daemon_resource_state(state_dir)?; + let current_record = version_state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()); + let current_snapshot = + current_record.and_then(|record| read_snapshot(&record.snapshot_path).ok().flatten()); + let (Some(current_record), Some(current_snapshot)) = (current_record, current_snapshot) else { + return Ok(serde_json::json!({ "available": false })); + }; + let previous_record = version_state + .as_ref() + .and_then(|state| state.previous_available_version.as_ref()); + let previous_snapshot = + previous_record.and_then(|record| read_snapshot(&record.snapshot_path).ok().flatten()); + let 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(¤t_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 { + 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 { + 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)> { + 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)> { + 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 { + sqlite_file_exists_no_symlink(path, "资源索引数据库") +} + +fn sqlite_file_exists_no_symlink(path: &Path, label: &str) -> anyhow::Result { + 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 { + 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 { + 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::>(); + 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 { + 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::>(); + 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 { + 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(¤t_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, result: serde_json::Value) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0", + id, + result: Some(result), + error: None, + } +} + +fn json_rpc_error(id: Option, code: i32, message: String) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0", + id, + result: None, + error: Some(JsonRpcError { code, message }), + } +} + +fn rpc_task_id_param(request: &JsonRpcRequest) -> &str { + request + .params + .as_ref() + .and_then(|params| params.get("task_id")) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() +} + +fn rpc_bool_param(params: Option<&serde_json::Value>, key: &str) -> Option { + params + .and_then(|params| params.get(key)) + .and_then(serde_json::Value::as_bool) +} + +fn rpc_struct_params( + params: Option<&serde_json::Value>, + method: &'static str, +) -> Result { + 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> { + 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::() + .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> { + 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 { + let normalized = value + .chars() + .filter(|ch| !matches!(ch, '_' | '-' | ' ')) + .collect::() + .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 { + let normalized = value + .chars() + .filter(|ch| !matches!(ch, '_' | '-' | ' ')) + .collect::() + .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 { + 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 { + let tail = params + .and_then(|params| params.get("tail")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(default as u64); + let tail = usize::try_from(tail)?; + if tail == 0 { + return Err(anyhow::anyhow!("tail 必须大于 0")); + } + Ok(tail) +} + +#[cfg(unix)] +fn daemon_rpc_call( + state_dir: &Path, + method: &str, + params: Option, +) -> anyhow::Result { + let socket_path = daemon_socket_path(state_dir); + ensure_daemon_socket_not_symlink(&socket_path)?; + let mut stream = UnixStream::connect(&socket_path).map_err(|error| { + anyhow::anyhow!("无法连接后台 RPC socket {}:{error}", socket_path.display()) + })?; + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params.unwrap_or(serde_json::Value::Null), + }); + serde_json::to_writer(&mut stream, &request)?; + stream.write_all(b"\n")?; + stream.flush()?; + + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line)?; + if line.trim().is_empty() { + return Err(anyhow::anyhow!("后台 RPC socket 未返回响应")); + } + let response: JsonRpcClientResponse = serde_json::from_str(&line)?; + if let Some(error) = response.error { + return Err(anyhow::anyhow!( + "后台 RPC {} 传输层失败:{} ({})", + method, + error.message, + error.code + )); + } + // 解包应用层 envelope:检查 ok,成功返回 data,失败把 envelope.error 转为 Err。 + let envelope = response + .result + .ok_or_else(|| anyhow::anyhow!("后台 RPC {method} 未返回 result"))?; + let ok = envelope + .get("ok") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if !ok { + let error = envelope.get("error"); + let code = error + .and_then(|error| error.get("code")) + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let message = error + .and_then(|error| error.get("message")) + .and_then(serde_json::Value::as_str) + .unwrap_or("未知错误"); + return Err(anyhow::anyhow!( + "后台 RPC {method} 失败:[{code}] {message}" + )); + } + Ok(envelope + .get("data") + .cloned() + .unwrap_or(serde_json::Value::Null)) +} + +#[cfg(not(unix))] +fn daemon_rpc_call( + _state_dir: &Path, + _method: &str, + _params: Option, +) -> anyhow::Result { + Err(anyhow::anyhow!("daemon RPC 目前只支持 Unix/Linux 平台")) +} + +#[cfg(unix)] +fn daemon_rpc_available(state_dir: &Path) -> bool { + let socket_path = daemon_socket_path(state_dir); + ensure_daemon_socket_not_symlink(&socket_path).is_ok() + && UnixStream::connect(socket_path).is_ok() +} + +#[cfg(not(unix))] +fn daemon_rpc_available(_state_dir: &Path) -> bool { + false +} + +#[cfg(unix)] +fn ensure_daemon_socket_not_symlink(socket_path: &Path) -> anyhow::Result<()> { + match fs::symlink_metadata(socket_path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow::anyhow!( + "后台 RPC socket 不能是 symlink:{}", + socket_path.display() + )), + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +#[cfg(unix)] +fn daemon_socket_path_exists(socket_path: &Path) -> anyhow::Result { + path_exists_no_follow(socket_path).map_err(|error| { + anyhow::anyhow!( + "后台 RPC socket 路径检查失败 {}:{error}", + socket_path.display() + ) + }) +} + +#[cfg(not(unix))] +fn daemon_socket_path_exists(socket_path: &Path) -> anyhow::Result { + path_exists_no_follow(socket_path) +} + +fn path_exists_no_follow(path: &Path) -> anyhow::Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +#[derive(Debug, Serialize)] +struct DaemonStartReport { + status: &'static str, + message: &'static str, + pid: u32, + resource_output_root: PathBuf, + 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, + resource_output_root: Option, + localized_output_root: Option, + state_dir: PathBuf, + pid_path: PathBuf, + status_path: PathBuf, + socket_path: PathBuf, + rpc_available: bool, + stale_pid_file: bool, + stale_socket: bool, + log_path: Option, + structured_log_path: Option, + rotated_structured_log_paths: Vec, + started_unix_seconds: Option, + updated_unix_seconds: Option, + last_success_unix_seconds: Option, + next_check_unix_seconds: Option, + daemon_state: Option, + last_update_status: Option, + last_error: Option, + next_retry_seconds: Option, + current_stage: Option, + status_code: Option, + current_message: Option, + download_progress: Option, + version_state_path: Option, + version_state: Option, + pending_scheduled_force: Option, + next_forced_refresh_unix_seconds: Option, + command: Option>, +} + +#[derive(Debug, Serialize)] +struct DaemonStopReport { + status: &'static str, + message: &'static str, + stopped: bool, + pid: Option, + state_dir: PathBuf, + pid_path: PathBuf, + socket_path: PathBuf, +} + +fn run_daemon_start(options: CliOptions) -> anyhow::Result<()> { + if options.config.dry_run { + return Err(anyhow::anyhow!("daemon 后台模式不能和 --dry-run 同时使用")); + } + if options.watch { + return Err(anyhow::anyhow!( + "--daemon 会自动启动 watch 模式,不要同时传 --watch" + )); + } + + let report = start_daemon_with_options(&options)?; + print_report(options.output_format, &report)?; + Ok(()) +} + +fn start_daemon_with_options(options: &CliOptions) -> anyhow::Result { + let resource_output_root = normalized_abs_path(&options.config.output_root)?; + let 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 { + 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, + proxy_url: Option, + message: &'static str, +) -> anyhow::Result { + validate_runtime_state_dir(&state_dir).map_err(anyhow::Error::msg)?; + fs::create_dir_all(&state_dir)?; + let pid_path = daemon_pid_path(&state_dir); + let status_path = daemon_status_path(&state_dir); + let log_path = daemon_log_path(&state_dir); + let structured_log_path = daemon_structured_log_path(&state_dir); + let socket_path = daemon_socket_path(&state_dir); + + match classify_pid_lock_file(&pid_path)? { + PidLockState::Active(existing_pid) => { + return Err(anyhow::anyhow!( + "官方同步后台进程已经在运行,pid={existing_pid}" + )); + } + PidLockState::StalePid(_) | PidLockState::Corrupt => { + let _ = fs::remove_file(&pid_path); + let _ = fs::remove_file(&socket_path); + } + PidLockState::Missing => {} + } + + // 代理凭据经环境变量下传子进程(/proc//environ 仅属主可读), + // 并写入专用 0600 文件供后续 restart/reload 复用;无代理时清除残留凭据。 + match proxy_url.as_deref() { + Some(url) => write_daemon_proxy_secret(&state_dir, url)?, + None => clear_daemon_proxy_secret(&state_dir), + } + + let executable = env::current_exe()?; + let log = + open_append_file(&log_path, PRIVATE_FILE_MODE, "后台日志").map_err(anyhow::Error::msg)?; + let log_for_stdout = log.try_clone()?; + let mut command = Command::new(&executable); + command + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::from(log_for_stdout)) + .stderr(Stdio::from(log)); + match proxy_url.as_deref() { + Some(url) => { + command.env(PROXY_URL_ENV_VAR, url); + } + None => { + command.env_remove(PROXY_URL_ENV_VAR); + } + } + configure_daemon_command(&mut command); + let child = match command.spawn() { + Ok(child) => child, + Err(error) => { + // 子进程未能启动,清除刚写入的代理凭据,避免残留。 + clear_daemon_proxy_secret(&state_dir); + return Err(error.into()); + } + }; + let pid = child.id(); + + if let Err(error) = write_file_atomic( + &pid_path, + pid.to_string().as_bytes(), + PRIVATE_FILE_MODE, + "后台 PID 文件", + ) + .map_err(anyhow::Error::msg) + { + let _ = terminate_process(pid); + clear_daemon_proxy_secret(&state_dir); + return Err(error); + } + let mut command = Vec::with_capacity(args.len() + 1); + command.push(executable.to_string_lossy().to_string()); + command.extend(args); + let status = DaemonStatusFile { + version: DAEMON_STATUS_VERSION, + pid, + state: "started".to_string(), + resource_output_root: resource_output_root.clone(), + 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 { + let pid_path = daemon_pid_path(state_dir); + let pid_state = classify_pid_lock_file(&pid_path)?; + let (pid, running) = match pid_state { + PidLockState::Missing => { + return Ok(DaemonStopReport { + status: "not_running", + message: "后台进程当前未运行", + stopped: false, + pid: None, + state_dir: state_dir.to_path_buf(), + pid_path, + socket_path: daemon_socket_path(state_dir), + }); + } + PidLockState::Corrupt => { + let _ = fs::remove_file(&pid_path); + let _ = fs::remove_file(daemon_socket_path(state_dir)); + return Ok(DaemonStopReport { + status: "stale_pid_removed", + message: "已清理无效的后台 PID 文件", + stopped: false, + pid: None, + state_dir: state_dir.to_path_buf(), + pid_path, + socket_path: daemon_socket_path(state_dir), + }); + } + PidLockState::StalePid(pid) => (pid, false), + PidLockState::Active(pid) => { + terminate_process(pid)?; + if !wait_for_process_exit(pid, Duration::from_secs(5)) { + return Err(anyhow::anyhow!("后台进程 pid={pid} 在 5 秒内未停止")); + } + (pid, true) + } + }; + let _ = fs::remove_file(&pid_path); + let _ = fs::remove_file(daemon_socket_path(state_dir)); + + Ok(DaemonStopReport { + status: if running { + "stopped" + } else { + "stale_pid_removed" + }, + message: if running { + "后台进程已停止" + } else { + "已清理失效的后台 PID 文件" + }, + stopped: running, + pid: Some(pid), + state_dir: state_dir.to_path_buf(), + pid_path, + socket_path: daemon_socket_path(state_dir), + }) +} + +fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result { + let pid_path = daemon_pid_path(state_dir); + let status_path = daemon_status_path(state_dir); + let socket_path = daemon_socket_path(state_dir); + let status_file = read_daemon_status_file(&status_path)?; + let pid_file_state = classify_pid_lock_file(&pid_path)?; + let pid_file_pid = match pid_file_state { + PidLockState::Active(pid) | PidLockState::StalePid(pid) => Some(pid), + PidLockState::Missing | PidLockState::Corrupt => None, + }; + let pid = pid_file_pid.or_else(|| status_file.as_ref().map(|status| status.pid)); + let running = pid.map(process_exists).unwrap_or(false); + let rpc_available = daemon_rpc_available(state_dir); + let stale_pid_file = matches!( + pid_file_state, + PidLockState::StalePid(_) | PidLockState::Corrupt + ); + let stale_socket = daemon_socket_path_exists(&socket_path)? && !rpc_available; + let default_log_path = daemon_log_path(state_dir); + let default_log_exists = path_exists_no_follow(&default_log_path)?; + let structured_log_path = status_file + .as_ref() + .and_then(|status| status.structured_log_path.clone()) + .unwrap_or_else(|| daemon_structured_log_path(state_dir)); + let structured_log_exists = path_exists_no_follow(&structured_log_path)?; + let rotated_structured_log_paths = rotated_structured_log_paths(&structured_log_path); + let version_state_path = status_file.as_ref().map(|status| { + status + .resource_output_root + .join("official-version-state.json") + }); + let version_state = version_state_path + .as_ref() + .and_then(|path| read_version_state(path).ok().flatten()); + + Ok(DaemonStatusReport { + status: if running { "running" } else { "stopped" }, + message: if running { + "后台进程正在运行" + } else if stale_pid_file || stale_socket { + "后台进程当前未运行,但检测到失效 PID/socket;可执行 clean-stable 清理" + } else { + "后台进程当前未运行" + }, + running, + pid, + resource_output_root: status_file + .as_ref() + .map(|status| status.resource_output_root.clone()), + 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) -> Vec { + let mut redacted = command; + for index in 0..redacted.len() { + if redacted[index] == "--proxy" { + if let Some(value) = redacted.get_mut(index + 1) { + *value = redact_proxy_url(value); + } + } + } + redacted +} + +#[derive(Debug, Serialize)] +struct DaemonControlReport { + command: &'static str, + status: &'static str, + message: &'static str, + strategy: &'static str, + previous_pid: Option, + pid: u32, + state_dir: PathBuf, + resource_output_root: PathBuf, + log_path: PathBuf, + socket_path: PathBuf, +} + +fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyhow::Result<()> { + let has_explicit_options = options.sync_option_explicit + || options.output_explicit + || options.proxy_option_explicit + || tools_are_non_default(&options.config, &options.env_baseline_config); + if command_name == "reload" && !has_explicit_options && daemon_rpc_available(&options.state_dir) + { + let report = daemon_rpc_call(&options.state_dir, RPC_METHOD_RELOAD, None)?; + print_json_value(options.output_format, &report)?; + return Ok(()); + } + + let status_file = read_daemon_status_file(&daemon_status_path(&options.state_dir))?; + // 在停止旧后台前读取已保存的代理凭据,供复用型重启还原。 + let saved_proxy_url = read_daemon_proxy_secret(&options.state_dir)?; + let status_report = build_daemon_status_report(&options.state_dir)?; + let previous_pid = status_report.pid.filter(|pid| process_exists(*pid)); + + if previous_pid.is_some() { + if daemon_rpc_available(&options.state_dir) + && daemon_rpc_call(&options.state_dir, RPC_METHOD_STOP, None).is_ok() + { + if let Some(pid) = previous_pid { + let _ = wait_for_rpc_stop_or_terminate(pid, Duration::from_secs(10))?; + } + let _ = fs::remove_file(daemon_pid_path(&options.state_dir)); + let _ = fs::remove_file(daemon_socket_path(&options.state_dir)); + } else { + let _ = stop_daemon_inner(&options.state_dir)?; + } + } else if read_pid_file(&daemon_pid_path(&options.state_dir))?.is_some() { + let _ = stop_daemon_inner(&options.state_dir)?; + } + + let (state_dir, resource_output_root, 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::>(); + 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 { + 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) -> anyhow::Result, +) -> 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(format: OutputFormat, report: &T) -> anyhow::Result<()> +where + T: Serialize + HumanReport, +{ + match format { + OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?), + OutputFormat::Human => report.print_human()?, + } + Ok(()) +} + +fn print_json_value(format: OutputFormat, value: &serde_json::Value) -> anyhow::Result<()> { + match format { + OutputFormat::Json => println!("{}", serde_json::to_string_pretty(value)?), + OutputFormat::Human => print_human_json_value(value)?, + } + Ok(()) +} + +trait HumanReport { + fn print_human(&self) -> anyhow::Result<()>; +} + +fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> { + if value.get("running").is_some() && value.get("state_dir").is_some() { + print_title("后台状态"); + print_json_field(value, "status", "状态"); + print_json_field(value, "message", "消息"); + print_json_field(value, "running", "运行中"); + print_json_field(value, "pid", "PID"); + print_json_field(value, "daemon_state", "后台状态"); + print_json_field(value, "rpc_available", "RPC 可用"); + print_json_field(value, "stale_pid_file", "失效 PID"); + print_json_field(value, "stale_socket", "失效 socket"); + print_json_field(value, "last_update_status", "上次同步"); + print_json_field(value, "last_success_unix_seconds", "最后成功时间"); + print_json_field(value, "last_error", "上次错误"); + print_json_field(value, "next_retry_seconds", "下次重试秒数"); + print_json_field(value, "next_check_unix_seconds", "下次检查时间"); + print_json_field(value, "current_stage", "当前阶段"); + print_json_field(value, "current_message", "当前消息"); + print_daemon_download_progress_json_summary(value.get("download_progress")); + print_json_field(value, "version_state_path", "版本状态路径"); + print_daemon_version_state_json_summary(value.get("version_state")); + print_json_field(value, "resource_output_root", "资源目录"); + print_json_field(value, "state_dir", "状态目录"); + print_json_field(value, "socket_path", "socket"); + print_json_field(value, "log_path", "日志"); + print_json_field(value, "structured_log_path", "结构化日志"); + print_json_field(value, "rotated_structured_log_paths", "轮转日志"); + return Ok(()); + } + if value.get("command").and_then(serde_json::Value::as_str) == Some("logs") { + print_title("后台日志"); + print_json_field(value, "status", "状态"); + print_json_field(value, "message", "消息"); + print_json_field(value, "log_path", "日志"); + print_json_field(value, "bytes", "字节"); + print_json_field(value, "total_lines", "总行数"); + print_json_field(value, "returned_lines", "返回行数"); + if let Some(content) = value.get("content").and_then(serde_json::Value::as_str) { + if !content.is_empty() { + println!(); + println!("{content}"); + } + } + return Ok(()); + } + if value.get("command").is_some() && value.get("status").is_some() { + print_title("后台命令"); + print_json_field(value, "command", "命令"); + print_json_field(value, "status", "状态"); + print_json_field(value, "message", "消息"); + print_json_field(value, "force", "force"); + print_json_field(value, "state_dir", "状态目录"); + print_json_field(value, "socket_path", "socket"); + return Ok(()); + } + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +} + +fn print_daemon_download_progress_json_summary(value: Option<&serde_json::Value>) { + let Some(value) = value else { + return; + }; + if value.is_null() { + return; + } + print_field( + "下载进度", + format_daemon_download_progress_json(value) + .unwrap_or_else(|error| format!("无法解析:{error}")), + ); +} + +fn format_daemon_download_progress_json(value: &serde_json::Value) -> Result { + let progress = serde_json::from_value::(value.clone()) + .map_err(|error| error.to_string())?; + Ok(format_daemon_download_progress(&progress)) +} + +fn print_daemon_version_state_json_summary(value: Option<&serde_json::Value>) { + let Some(value) = value else { + return; + }; + if value.is_null() { + return; + } + match serde_json::from_value::(value.clone()) { + Ok(version_state) => print_daemon_version_state_summary(&version_state), + Err(error) => print_field("版本状态", format!("无法解析:{error}")), + } +} + +fn print_daemon_version_state_summary(version_state: &OfficialVersionState) { + print_optional_field( + "当前完成版本", + version_state + .current_completed_version + .as_ref() + .map(|version| version.id.as_str()), + ); + print_optional_field( + "正在拉取版本", + version_state + .in_progress_version + .as_ref() + .map(|version| version.id.as_str()), + ); + print_optional_field( + "上一个可用版本", + version_state + .previous_available_version + .as_ref() + .map(|version| version.id.as_str()), + ); + let historical_failures = visible_historical_failed_versions(version_state); + print_field("历史失败版本数", historical_failures.len()); + if let Some(failed) = historical_failures.last() { + print_field("最近历史失败版本", &failed.version.id); + print_field("最近历史失败时间", failed.failed_unix_seconds); + print_field("最近历史失败原因", &failed.error); + } +} + +fn visible_historical_failed_versions( + version_state: &OfficialVersionState, +) -> Vec<&OfficialFailedVersionRecord> { + let in_progress = version_state.in_progress_version.as_ref(); + version_state + .failed_versions + .iter() + .filter(|failed| { + !in_progress.is_some_and(|version| version_matches_for_status(&failed.version, version)) + }) + .collect() +} + +fn version_matches_for_status(left: &OfficialVersionRecord, right: &OfficialVersionRecord) -> bool { + left.app_version == right.app_version + && left.bundle_version == right.bundle_version + && left.addressables_root == right.addressables_root +} + +fn print_json_field(value: &serde_json::Value, key: &str, label: &str) { + let Some(value) = value.get(key) else { + return; + }; + if value.is_null() { + return; + } + if let Some(value) = value.as_str() { + print_field(label, value); + } else { + print_field(label, value); + } +} + +fn print_title(title: &str) { + println!("{title}"); +} + +fn print_field(label: &str, value: impl std::fmt::Display) { + println!(" {label:<18} {value}"); +} + +fn print_optional_field(label: &str, value: Option) +where + T: std::fmt::Display, +{ + if let Some(value) = value { + print_field(label, value); + } +} + +fn print_path_field(label: &str, value: &Path) { + print_field(label, value.display()); +} + +fn print_optional_path_field(label: &str, value: Option<&PathBuf>) { + if let Some(value) = value { + print_path_field(label, value); + } +} + +fn print_list(label: &str, values: &[String], limit: usize) { + if values.is_empty() { + return; + } + println!(" {label}:"); + for value in values.iter().take(limit) { + println!(" - {value}"); + } + if values.len() > limit { + println!(" ... 还有 {} 项", values.len() - limit); + } +} + +fn format_daemon_download_progress(progress: &DaemonDownloadProgress) -> String { + let status = progress.status.as_deref().unwrap_or("running"); + if 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 { + 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::>() + .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::>(); + 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 HumanReport for CommandReport +where + T: Serialize + HumanReport, +{ + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("命令", self.command); + print_field("状态", self.status); + self.data.print_human() + } +} + +impl HumanReport for 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::>(); + print_list("轮转日志", &rotated, 5); + Ok(()) + } +} + +impl HumanReport for DaemonStopReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("状态", self.status); + print_field("已停止", format_bool(self.stopped)); + print_optional_field("PID", self.pid); + print_path_field("状态目录", &self.state_dir); + print_path_field("socket", &self.socket_path); + Ok(()) + } +} + +impl HumanReport for DaemonControlReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("命令", self.command); + print_field("状态", self.status); + print_field("策略", self.strategy); + print_optional_field("旧 PID", self.previous_pid); + print_field("PID", self.pid); + print_path_field("资源目录", &self.resource_output_root); + print_path_field("状态目录", &self.state_dir); + print_path_field("socket", &self.socket_path); + print_path_field("日志", &self.log_path); + Ok(()) + } +} + +impl HumanReport for VerifyCommandReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("命令", self.command); + print_field("状态", self.status); + print_field("健康", format_bool(self.healthy)); + print_field("远端状态", &self.remote_update_status); + print_path_field("校验资源目录", &self.verified_resource_root); + print_optional_field("计划 URL 数", self.planned_url_count); + print_field("计划异常数", self.expected_plan_failure_count); + print_field("本地 manifest 项", self.local_manifest_entry_count); + print_field("本地校验通过", self.local_manifest_verified_count); + print_field("本地失败数", self.local_manifest_failure_count); + print_field("官方 hash 对", self.official_hash_pair_count); + print_field("官方 hash 通过", self.official_hash_verified_count); + print_verification_summary(&self.verification_summary); + if !self.official_hash_errors.is_empty() { + print_list("官方 hash 错误", &self.official_hash_errors, 8); + } + if !self.failures.is_empty() { + println!(" 失败项:"); + for item in self.failures.iter().take(12) { + println!(" - {} -> {}", item.status, item.destination.display()); + } + if self.failures.len() > 12 { + println!(" ... 还有 {} 项", self.failures.len() - 12); + } + } + Ok(()) + } +} + +impl HumanReport for LogsReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("状态", self.status); + print_path_field("日志", &self.log_path); + print_field("存在", format_bool(self.exists)); + print_field("为空", format_bool(self.empty)); + print_field("字节", self.bytes); + print_field("总行数", self.total_lines); + print_field("返回行数", self.returned_lines); + if !self.content.is_empty() { + println!(); + println!("{}", self.content); + } + Ok(()) + } +} + +impl HumanReport for DoctorReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("命令", self.command); + print_field("状态", self.status); + print_field("健康", format_bool(self.healthy)); + println!(" 检查:"); + for check in &self.checks { + println!( + " [{}] {} - {}", + if check.ok { "OK" } else { "FAIL" }, + check.name, + check.message + ); + } + Ok(()) + } +} + +impl HumanReport for CleanStableReport { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("命令", self.command); + print_field("状态", self.status); + print_path_field("资源目录", &self.output_root); + print_path_field("状态目录", &self.state_dir); + if !self.removed_paths.is_empty() { + println!(" 已清理:"); + for path in &self.removed_paths { + println!(" - {}", path.display()); + } + } + if !self.skipped_paths.is_empty() { + println!(" 已跳过:"); + for path in &self.skipped_paths { + println!(" - {}", path.display()); + } + } + Ok(()) + } +} + +impl HumanReport for DaemonRpcAck { + fn print_human(&self) -> anyhow::Result<()> { + print_title(self.message); + print_field("命令", self.command); + print_field("状态", self.status); + print_optional_field("force", self.force.map(format_bool)); + print_path_field("状态目录", &self.state_dir); + print_path_field("socket", &self.socket_path); + Ok(()) + } +} + +#[derive(Debug, Serialize)] +struct VerificationItemReport { + url: String, + destination: PathBuf, + status: String, + expected_bytes: Option, + actual_bytes: Option, + expected_blake3: Option, + actual_blake3: Option, + zip_error: Option, +} + +#[derive(Debug, Serialize)] +struct VerifyCommandReport { + command: &'static str, + status: &'static str, + message: &'static str, + healthy: bool, + remote_update_status: String, + verified_resource_root: PathBuf, + planned_url_count: Option, + expected_plan_failure_count: usize, + local_manifest_entry_count: usize, + local_manifest_verified_count: usize, + local_manifest_failure_count: usize, + official_hash_pair_count: usize, + official_hash_verified_count: usize, + verification_summary: OfficialVerificationSummary, + official_hash_errors: Vec, + failures: Vec, +} + +fn run_verify_command(options: &CliOptions) -> anyhow::Result { + let mut config = options.config.clone(); + config.dry_run = true; + config.plan = true; + config.audit_local = true; + config.repair = false; + config.force = false; + + let mut logger = ProgressLogger::new(options.progress); + let update_report = + OfficialUpdateService::new().run_with_progress(&config, |event| logger.log(event))?; + let verified_resource_root = active_official_resource_root(&config.output_root)?; + let verification = bat_infrastructure::OfficialResourcePullService::with_curl_command( + &verified_resource_root, + &config.curl_command, + ) + .with_proxy_config(config.curl_proxy.clone()) + .verify_local_download_manifest() + .map_err(anyhow::Error::msg)?; + let failures = verification + .items + .iter() + .filter(|item| !item.status.is_verified()) + .map(|item| VerificationItemReport { + url: item.url.clone(), + destination: item.destination.clone(), + status: item.status.as_str().to_string(), + expected_bytes: item.expected_bytes, + actual_bytes: item.actual_bytes, + expected_blake3: item.expected_blake3.clone(), + actual_blake3: item.actual_blake3.clone(), + zip_error: item.zip_error.clone(), + }) + .collect::>(); + let healthy = update_report.update_status == OfficialUpdateStatus::UpToDate + && update_report.local_manifest_repair_needed_count == 0 + && verification.is_clean(); + let verification_summary = OfficialVerificationSummary::new( + verification.manifest_blake3_verified_count(), + verification.failure_count(), + verification.official_hash_verified_count, + verification.zip_structure_verified_count(), + ); + let report = VerifyCommandReport { + command: "verify", + status: if healthy { "verified" } else { "failed" }, + message: if healthy { + "所有当前计划资源和本地 manifest 均通过验证" + } else { + "发现资源或官方 seed hash 校验异常" + }, + healthy, + remote_update_status: update_report.update_status.as_str().to_string(), + verified_resource_root, + planned_url_count: update_report.download_url_count, + expected_plan_failure_count: update_report.local_manifest_repair_needed_count, + local_manifest_entry_count: verification.items.len(), + local_manifest_verified_count: verification.verified_count(), + local_manifest_failure_count: verification.failure_count(), + official_hash_pair_count: verification.official_hash_pair_count, + official_hash_verified_count: verification.official_hash_verified_count, + verification_summary, + official_hash_errors: verification.official_hash_errors, + failures, + }; + print_report(options.output_format, &report)?; + Ok(healthy) +} + +#[derive(Debug, Serialize)] +struct LogsReport { + command: &'static str, + status: &'static str, + message: &'static str, + log_path: PathBuf, + exists: bool, + empty: bool, + bytes: usize, + total_lines: usize, + returned_lines: usize, + content: String, +} + +fn run_logs_command(options: &CliOptions) -> anyhow::Result<()> { + if daemon_rpc_available(&options.state_dir) { + let report = daemon_rpc_call( + &options.state_dir, + RPC_METHOD_LOGS, + Some(serde_json::json!({ "tail": options.tail_lines })), + )?; + print_json_value(options.output_format, &report)?; + return Ok(()); + } + + let report = build_logs_report(&options.state_dir, options.tail_lines)?; + print_report(options.output_format, &report)?; + Ok(()) +} + +fn build_logs_report(state_dir: &Path, tail_lines: usize) -> anyhow::Result { + let status_file = read_daemon_status_file(&daemon_status_path(state_dir))?; + let log_path = status_file + .as_ref() + .map(|status| status.log_path.clone()) + .unwrap_or_else(|| daemon_log_path(state_dir)); + let bytes = read_file_no_symlink(&log_path, "后台日志").map_err(anyhow::Error::msg)?; + let exists = bytes.is_some(); + let bytes = bytes.unwrap_or_default(); + let raw = String::from_utf8_lossy(&bytes); + let lines = raw.lines().collect::>(); + let start = lines.len().saturating_sub(tail_lines); + let content = lines[start..].join("\n"); + Ok(LogsReport { + command: "logs", + status: if bytes.is_empty() { "empty" } else { "ok" }, + message: if bytes.is_empty() { + "后台日志为空或尚未创建" + } else { + "已读取后台日志" + }, + log_path, + exists, + empty: bytes.is_empty(), + bytes: bytes.len(), + total_lines: lines.len(), + returned_lines: lines.len().saturating_sub(start), + content, + }) +} + +#[derive(Debug, Serialize)] +struct DoctorCheck { + name: &'static str, + ok: bool, + message: String, +} + +#[derive(Debug, Serialize)] +struct DoctorReport { + command: &'static str, + status: &'static str, + message: &'static str, + healthy: bool, + checks: Vec, +} + +fn build_doctor_report( + state_dir: &Path, + config: &OfficialUpdateConfig, +) -> anyhow::Result { + 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 { + 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, + skipped_paths: Vec, +} + +fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> { + validate_output_root(&options.config.output_root).map_err(anyhow::Error::msg)?; + validate_runtime_state_dir(&options.state_dir).map_err(anyhow::Error::msg)?; + let status = build_daemon_status_report(&options.state_dir)?; + if status.running || status.rpc_available { + return Err(anyhow::anyhow!( + "后台进程正在运行,不能执行 clean-stable;请先执行 stop" + )); + } + + let mut removed_paths = Vec::new(); + let mut skipped_paths = Vec::new(); + for root in [&options.config.output_root, &options.state_dir] { + collect_transient_files(root, &mut removed_paths)?; + } + + let pid_path = daemon_pid_path(&options.state_dir); + match classify_pid_lock_file(&pid_path)? { + PidLockState::Missing => {} + PidLockState::Active(_) => skipped_paths.push(pid_path), + PidLockState::StalePid(_) | PidLockState::Corrupt => { + fs::remove_file(&pid_path)?; + removed_paths.push(pid_path); + } + } + + let socket_path = daemon_socket_path(&options.state_dir); + if daemon_socket_path_exists(&socket_path)? { + if daemon_rpc_available(&options.state_dir) { + skipped_paths.push(socket_path); + } else { + fs::remove_file(&socket_path)?; + removed_paths.push(socket_path); + } + } + + let lock_path = options.config.lock_path(); + match classify_pid_lock_file(&lock_path)? { + PidLockState::Missing => {} + PidLockState::Active(_) => skipped_paths.push(lock_path), + PidLockState::StalePid(_) | PidLockState::Corrupt => { + fs::remove_file(&lock_path)?; + removed_paths.push(lock_path); + } + } + + let control_lock_path = daemon_control_lock_path(&options.state_dir); + match classify_pid_lock_file(&control_lock_path)? { + PidLockState::Missing => {} + PidLockState::Active(pid) => { + return Err(anyhow::anyhow!( + "后台控制命令已被锁定 (locked):{};owner_pid={pid} 仍在运行", + control_lock_path.display() + )); + } + PidLockState::StalePid(_) | PidLockState::Corrupt => { + fs::remove_file(&control_lock_path)?; + removed_paths.push(control_lock_path); + } + } + + // 后台已停止,清除残留的代理凭据文件,不把凭据留在磁盘上。 + let proxy_secret_path = daemon_proxy_secret_path(&options.state_dir); + if proxy_secret_path.exists() { + fs::remove_file(&proxy_secret_path)?; + removed_paths.push(proxy_secret_path); + } + + // 后台已停止,清理未被版本状态引用的孤儿 staging 目录。 + if let Some(state) = read_version_state(&options.config.version_state_path())? { + removed_paths.extend(gc_orphan_staging(&options.config.output_root, &state)?); + } + + let report = CleanStableReport { + command: "clean-stable", + status: if skipped_paths.is_empty() { + "cleaned" + } else { + "cleaned_with_skips" + }, + message: "已清理断点临时文件、临时状态文件和失效锁/PID;不会删除正式资源", + output_root: options.config.output_root.clone(), + state_dir: options.state_dir.clone(), + removed_paths, + skipped_paths, + }; + print_report(options.output_format, &report)?; + Ok(()) +} + +fn collect_transient_files(root: &Path, removed_paths: &mut Vec) -> anyhow::Result<()> { + if !root.exists() { + return Ok(()); + } + let metadata = fs::symlink_metadata(root)?; + if !metadata.is_dir() { + return Ok(()); + } + + for entry in fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path)?; + if metadata.is_dir() { + collect_transient_files(&path, removed_paths)?; + continue; + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + if file_name.ends_with(".part") || file_name.ends_with(".tmp") { + fs::remove_file(&path)?; + removed_paths.push(path); + } + } + Ok(()) +} + +fn read_daemon_status_file(path: &Path) -> anyhow::Result> { + let Some(bytes) = read_file_no_symlink(path, "后台状态文件").map_err(anyhow::Error::msg)? + else { + return Ok(None); + }; + Ok(Some(serde_json::from_slice(&bytes)?)) +} + +/// 串行化 `bat-status.json` 的读-改-写。 +/// +/// watch 循环线程(`update_daemon_status`/`update_daemon_progress`)与 RPC 处理线程 +/// (`update_daemon_state_only`)都会更新状态文件;单次写虽原子,但“读→改字段→写回” +/// 整体非原子,无此锁会因 last-writer-wins 丢失并发线程的字段更新。 +static DAEMON_STATUS_FILE_LOCK: Mutex<()> = Mutex::new(()); + +fn lock_daemon_status_file() -> std::sync::MutexGuard<'static, ()> { + DAEMON_STATUS_FILE_LOCK + .lock() + .unwrap_or_else(|poison| poison.into_inner()) +} + +fn write_daemon_status_file(path: &Path, status: &DaemonStatusFile) -> anyhow::Result<()> { + write_file_atomic( + path, + &serde_json::to_vec_pretty(status)?, + PRIVATE_FILE_MODE, + "后台状态文件", + ) + .map_err(anyhow::Error::msg)?; + Ok(()) +} + +fn update_daemon_status(state_dir: &Path, update: DaemonStatusUpdate<'_>) -> anyhow::Result<()> { + let _guard = lock_daemon_status_file(); + let status_path = daemon_status_path(state_dir); + let mut status = read_daemon_status_file(&status_path)?.unwrap_or_else(|| DaemonStatusFile { + version: DAEMON_STATUS_VERSION, + pid: std::process::id(), + state: "started".to_string(), + resource_output_root: OfficialUpdateConfig::default().output_root, + 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> { + let Some(bytes) = read_file_no_symlink(path, "后台 PID 文件").map_err(anyhow::Error::msg)? + else { + return Ok(None); + }; + let contents = String::from_utf8(bytes)?; + Ok(contents.trim().parse::().ok().filter(|pid| *pid > 0)) +} + +fn daemon_pid_path(state_dir: &Path) -> PathBuf { + state_dir.join(DAEMON_PID_FILE) +} + +fn daemon_status_path(state_dir: &Path) -> PathBuf { + state_dir.join(DAEMON_STATUS_FILE) +} + +fn daemon_log_path(state_dir: &Path) -> PathBuf { + state_dir.join(DAEMON_LOG_FILE) +} + +fn daemon_structured_log_path(state_dir: &Path) -> PathBuf { + state_dir.join(DAEMON_STRUCTURED_LOG_FILE) +} + +fn rotated_structured_log_path(path: &Path, index: usize) -> PathBuf { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(DAEMON_STRUCTURED_LOG_FILE); + path.with_file_name(format!("{file_name}.{index}")) +} + +fn rotated_structured_log_paths(path: &Path) -> Vec { + (1..=STRUCTURED_LOG_ROTATE_KEEP) + .map(|index| rotated_structured_log_path(path, index)) + .filter(|path| path_exists_no_follow(path).unwrap_or(false)) + .collect() +} + +fn daemon_socket_path(state_dir: &Path) -> PathBuf { + state_dir.join(DAEMON_SOCKET_FILE) +} + +fn daemon_control_lock_path(state_dir: &Path) -> PathBuf { + state_dir.join(DAEMON_CONTROL_LOCK_FILE) +} + +fn daemon_proxy_secret_path(state_dir: &Path) -> PathBuf { + state_dir.join(DAEMON_PROXY_SECRET_FILE) +} + +/// 返回 URL 型代理的凭据字符串;auto/disabled 模式返回 None。 +fn curl_proxy_url(config: &CurlProxyConfig) -> Option { + match config.mode() { + CurlProxyMode::Url(url) => Some(url.clone()), + CurlProxyMode::Auto | CurlProxyMode::Disabled => None, + } +} + +/// 将代理凭据写入专用 0600 文件,供 restart/reload 复用;该文件永不进入状态输出。 +fn write_daemon_proxy_secret(state_dir: &Path, url: &str) -> anyhow::Result<()> { + write_file_atomic( + &daemon_proxy_secret_path(state_dir), + url.as_bytes(), + PRIVATE_FILE_MODE, + "代理凭据文件", + ) + .map_err(anyhow::Error::msg) +} + +/// 删除代理凭据文件(best-effort);无代理或拆除后台时调用。 +fn clear_daemon_proxy_secret(state_dir: &Path) { + let _ = fs::remove_file(daemon_proxy_secret_path(state_dir)); +} + +/// 读取代理凭据文件;缺失或为空返回 None。 +fn read_daemon_proxy_secret(state_dir: &Path) -> anyhow::Result> { + let Some(bytes) = read_file_no_symlink(&daemon_proxy_secret_path(state_dir), "代理凭据文件") + .map_err(anyhow::Error::msg)? + else { + return Ok(None); + }; + let url = + String::from_utf8(bytes).map_err(|_| anyhow::anyhow!("代理凭据文件不是有效 UTF-8"))?; + let trimmed = url.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(trimmed.to_string())) + } +} + +fn active_official_resource_root(output_root: &Path) -> anyhow::Result { + let current_path = output_root.join(OFFICIAL_CURRENT_LINK); + let metadata = match fs::symlink_metadata(¤t_path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(output_root.to_path_buf()); + } + Err(error) => return Err(error.into()), + }; + if !metadata.file_type().is_symlink() { + return Err(anyhow::anyhow!( + "current 已存在但不是 symlink:{}", + current_path.display() + )); + } + let target = fs::read_link(¤t_path)?; + let target = if target.is_absolute() { + target + } else { + output_root.join(target) + }; + lexical_absolute(&target).map_err(anyhow::Error::msg) +} + +fn daemon_child_args(options: &CliOptions) -> Vec { + let mut args = Vec::new(); + let config = &options.config; + if config.auto_discover { + args.push("--auto-discover".to_string()); + } + if let Some(source) = config.server_info_source.as_ref() { + match source { + OfficialServerInfoSource::LocalPath(path) => { + args.push("--server-info-path".to_string()); + args.push(path.to_string_lossy().to_string()); + } + OfficialServerInfoSource::OfficialFile(name) => { + args.push("--server-info-file".to_string()); + args.push(name.clone()); + } + OfficialServerInfoSource::OfficialUrl(url) => { + args.push("--server-info-url".to_string()); + args.push(url.clone()); + } + } + } + if let Some(connection_group) = config.connection_group.as_ref() { + args.push("--connection-group".to_string()); + args.push(connection_group.clone()); + } + if let Some(app_version) = config.app_version.as_ref() { + args.push("--app-version".to_string()); + args.push(app_version.clone()); + } + args.push("--launcher-version".to_string()); + args.push(config.launcher_version.clone()); + if let Some(platforms) = config.platforms.as_ref() { + args.push("--platforms".to_string()); + args.push( + platforms + .iter() + .map(|platform| match platform { + PatchPlatform::Windows => "Windows", + PatchPlatform::Android => "Android", + }) + .collect::>() + .join(","), + ); + } + args.push("--output".to_string()); + args.push(config.output_root.to_string_lossy().to_string()); + 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//cmdline)与状态文件 command 字段。 + args.push(PROXY_FROM_ENV_FLAG.to_string()); + } + } + args.push("--unzip".to_string()); + args.push(config.unzip_command.to_string_lossy().to_string()); + if config.force { + args.push("--force".to_string()); + } + if config.audit_local { + args.push("--audit-local".to_string()); + } else { + args.push("--no-audit-local".to_string()); + } + if config.repair { + args.push("--repair".to_string()); + } else { + args.push("--no-repair".to_string()); + } + args.push("--state-dir".to_string()); + args.push(options.state_dir.to_string_lossy().to_string()); + args.push("--daemon-child".to_string()); + args.push("--watch".to_string()); + args.push("--interval".to_string()); + args.push(format_duration_arg(options.interval)); + args.push("--error-retry".to_string()); + args.push(format_duration_arg(options.error_retry_interval)); + if options.quiet_up_to_date { + args.push("--quiet-up-to-date".to_string()); + } else { + args.push("--no-quiet-up-to-date".to_string()); + } + if options.progress { + args.push("--progress".to_string()); + } else { + args.push("--no-progress".to_string()); + } + if options.output_format == OutputFormat::Json { + args.push("--json".to_string()); + } + if options.banner { + args.push("--banner".to_string()); + } else { + args.push("--no-banner".to_string()); + } + args +} + +fn format_duration_arg(duration: Duration) -> String { + let millis = duration.as_millis(); + if millis.is_multiple_of(3_600_000) { + format!("{}h", millis / 3_600_000) + } else if millis.is_multiple_of(60_000) { + format!("{}m", millis / 60_000) + } else if millis.is_multiple_of(1_000) { + format!("{}s", millis / 1_000) + } else { + format!("{millis}ms") + } +} + +fn unix_seconds_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn unix_seconds_after(duration: Duration) -> u64 { + unix_seconds_now().saturating_add(duration.as_secs()) +} + +fn system_time_to_unix_seconds(time: SystemTime) -> Option { + time.duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_secs()) +} + +#[cfg(unix)] +fn configure_daemon_command(command: &mut Command) { + use std::os::unix::process::CommandExt; + + unsafe { + command.pre_exec(|| { + if libc::setsid() < 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } +} + +#[cfg(not(unix))] +fn configure_daemon_command(_command: &mut Command) {} + +#[cfg(unix)] +fn process_exists(pid: u32) -> bool { + let Ok(pid) = libc::pid_t::try_from(pid) else { + return false; + }; + if pid <= 0 { + return false; + } + unsafe { + libc::kill(pid, 0) == 0 + || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) + } +} + +// 非 Unix 平台无法用 kill(pid, 0) 探测进程存活;生产链路只支持 Linux。 +// 这里保守返回 true(假定进程仍存活),避免误把可能仍在运行的 daemon 的 +// PID/锁文件当作 stale 而回收——宁可要求手动清理,也不冒重复启动的风险。 +#[cfg(not(unix))] +fn process_exists(_pid: u32) -> bool { + true +} + +#[cfg(unix)] +fn terminate_process(pid: u32) -> anyhow::Result<()> { + let pid = libc::pid_t::try_from(pid)?; + if pid <= 0 { + return Err(anyhow::anyhow!("无效的后台进程 pid")); + } + let rc = unsafe { libc::kill(pid, libc::SIGTERM) }; + if rc == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error().into()) + } +} + +#[cfg(not(unix))] +fn terminate_process(_pid: u32) -> anyhow::Result<()> { + Err(anyhow::anyhow!("stop 目前只支持 Unix/Linux 平台")) +} + +fn wait_for_process_exit(pid: u32, timeout: Duration) -> bool { + let started_at = Instant::now(); + while started_at.elapsed() < timeout { + if !process_exists(pid) { + return true; + } + thread::sleep(Duration::from_millis(100)); + } + !process_exists(pid) +} + +fn wait_for_rpc_stop_or_terminate(pid: u32, timeout: Duration) -> anyhow::Result { + if wait_for_process_exit(pid, timeout) { + return Ok(false); + } + terminate_process(pid)?; + if !wait_for_process_exit(pid, Duration::from_secs(5)) { + return Err(anyhow::anyhow!("后台进程 pid={pid} 在 SIGTERM 后仍未停止")); + } + Ok(true) +} + +fn next_forced_refresh_at_or_after(now: SystemTime) -> SystemTime { + forced_refresh_time(now, ForcedRefreshBoundary::AtOrAfter) +} + +fn next_forced_refresh_after(now: SystemTime) -> SystemTime { + forced_refresh_time(now, ForcedRefreshBoundary::After) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ForcedRefreshBoundary { + AtOrAfter, + After, +} + +fn forced_refresh_time(now: SystemTime, boundary: ForcedRefreshBoundary) -> SystemTime { + let local_seconds = beijing_local_seconds_since_epoch(now); + let local_day = local_seconds / SECONDS_PER_DAY; + let local_second_of_day = local_seconds % SECONDS_PER_DAY; + + for scheduled_second in DAILY_FORCED_REFRESH_LOCAL_SECONDS { + let matches_boundary = match boundary { + ForcedRefreshBoundary::AtOrAfter => scheduled_second >= local_second_of_day, + ForcedRefreshBoundary::After => scheduled_second > local_second_of_day, + }; + if matches_boundary { + return system_time_from_beijing_local_seconds( + local_day + .saturating_mul(SECONDS_PER_DAY) + .saturating_add(scheduled_second), + ); + } + } + + system_time_from_beijing_local_seconds( + local_day + .saturating_add(1) + .saturating_mul(SECONDS_PER_DAY) + .saturating_add(DAILY_FORCED_REFRESH_LOCAL_SECONDS[0]), + ) +} + +fn beijing_local_seconds_since_epoch(time: SystemTime) -> u64 { + time.duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .saturating_add(BEIJING_UTC_OFFSET_SECONDS) +} + +fn system_time_from_beijing_local_seconds(local_seconds: u64) -> SystemTime { + UNIX_EPOCH + Duration::from_secs(local_seconds.saturating_sub(BEIJING_UTC_OFFSET_SECONDS)) +} + +fn duration_until(deadline: SystemTime, now: SystemTime) -> Duration { + deadline.duration_since(now).unwrap_or_default() +} + +#[derive(Debug, Clone)] +struct ProgressLogger { + enabled: bool, + started_at: Instant, + structured: Option, +} + +impl ProgressLogger { + fn new(enabled: bool) -> Self { + Self { + enabled, + started_at: Instant::now(), + structured: None, + } + } + + fn attach_structured_log(&mut self, path: PathBuf) { + self.structured = Some(RotatingStructuredLogger::new( + path, + STRUCTURED_LOG_MAX_BYTES, + STRUCTURED_LOG_ROTATE_KEEP, + )); + } + + fn log(&mut self, event: OfficialUpdateProgress) { + self.log_event(&event); + } + + fn log_text(&mut self, stage: &str, message: impl AsRef) { + self.log_text_inner(stage, message.as_ref()); + } + + fn log_event(&mut self, event: &OfficialUpdateProgress) { + if self.enabled { + eprintln!( + "[+{} 信息] [{}] {}", + format_duration(self.started_at.elapsed()), + localized_stage(event.stage), + event.message + ); + } + if let Some(structured) = self.structured.as_mut() { + let _ = structured.write_event(self.started_at.elapsed(), event); + } + } + + fn log_text_inner(&mut self, stage: &str, message: &str) { + if !self.enabled { + if let Some(structured) = self.structured.as_mut() { + let event = OfficialUpdateProgress::new(stage_to_static(stage), message); + let _ = structured.write_event(self.started_at.elapsed(), &event); + } + return; + } + eprintln!( + "[+{} 信息] [{}] {}", + format_duration(self.started_at.elapsed()), + localized_stage(stage), + message + ); + if let Some(structured) = self.structured.as_mut() { + let event = OfficialUpdateProgress::new(stage_to_static(stage), message); + let _ = structured.write_event(self.started_at.elapsed(), &event); + } + } +} + +#[derive(Debug, Clone)] +struct RotatingStructuredLogger { + path: PathBuf, + max_bytes: u64, + keep: usize, +} + +impl RotatingStructuredLogger { + fn new(path: PathBuf, max_bytes: u64, keep: usize) -> Self { + Self { + path, + max_bytes, + keep, + } + } + + fn write_event( + &mut self, + elapsed: Duration, + event: &OfficialUpdateProgress, + ) -> anyhow::Result<()> { + let payload = serde_json::json!({ + "timestamp_unix_seconds": unix_seconds_now(), + "elapsed_ms": elapsed.as_millis(), + "level": "info", + "stage": event.stage, + "stage_label": localized_stage(event.stage), + "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 目录;未设置时默认 /.cas +BAT_IMPORT_CAS_ROOT= +# 官方资源 SQLite 索引;未设置时默认 /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-tasks.json; +# Redis 任务后端落地后以下配置才会生效。 +#BAT_REDIS_URL=redis://127.0.0.1:6379 +#BAT_REDIS_PASSWORD= +"#; + +/// `.env` 引导:首次启动时在二进制所在目录释放配置模板,之后每次启动把其中的 +/// 键加载为进程环境变量(不覆盖已存在的环境变量,保持"环境变量 > .env"优先级)。 +/// +/// 任何失败只在 stderr 警告、不中断启动——`.env` 是便利层,不是启动硬依赖。 +fn bootstrap_env_file() { + if env::var(SKIP_ENV_FILE_VAR).map(|value| value == "1") == Ok(true) { + return; + } + let Ok(exe_path) = env::current_exe() else { + return; + }; + let Some(exe_dir) = exe_path.parent() else { + return; + }; + let path = exe_dir.join(ENV_FILE_NAME); + if !path.exists() { + match write_env_template(&path) { + Ok(()) => eprintln!( + "已生成配置模板 {}(编辑其中的 BAT_* 配置后,直接运行 `bat` 即可按 .env 启动)", + path.display() + ), + Err(error) => { + eprintln!("警告:生成 .env 配置模板失败 {}:{error}", path.display()); + return; + } + } + } + match fs::read_to_string(&path) { + Ok(content) => apply_env_file(&content), + Err(error) => eprintln!("警告:读取 .env 失败 {}:{error}", path.display()), + } +} + +/// 以 `create_new` 原子创建模板文件,避免并发启动时互相覆盖;unix 下限制 `0600` +/// 权限(`.env` 可能保存代理凭据等敏感配置)。 +fn write_env_template(path: &Path) -> std::io::Result<()> { + let mut open_options = OpenOptions::new(); + open_options.write(true).create_new(true); + #[cfg(unix)] + open_options.mode(PRIVATE_FILE_MODE); + let mut file = open_options.open(path)?; + file.write_all(ENV_TEMPLATE.as_bytes()) +} + +/// 解析 `.env` 内容,把进程环境里尚不存在的键设为环境变量。 +fn apply_env_file(content: &str) { + for (line_number, raw_line) in content.lines().enumerate() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = parse_env_line(line) else { + eprintln!( + "警告:.env 第 {} 行无法解析,已忽略:{raw_line}", + line_number + 1 + ); + continue; + }; + if env::var_os(&key).is_none() { + env::set_var(&key, value); + } + } +} + +/// 解析单行 `KEY=VALUE`。key 须为 `[A-Za-z_][A-Za-z0-9_]*`;值两侧的成对 +/// 单/双引号会剥除。不支持 `export` 前缀和多行值。 +fn parse_env_line(line: &str) -> Option<(String, String)> { + let (key, value) = line.split_once('=')?; + let key = key.trim(); + let valid_key = !key.is_empty() + && key.chars().enumerate().all(|(index, character)| { + character == '_' + || character.is_ascii_alphabetic() + || (index > 0 && character.is_ascii_digit()) + }); + if !valid_key { + return None; + } + let mut value = value.trim(); + if value.len() >= 2 { + let bytes = value.as_bytes(); + let quoted = (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') + || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\''); + if quoted { + value = &value[1..value.len() - 1]; + } + } + Some((key.to_string(), value.to_string())) +} + +/// `.env`/环境变量提供的运行模式开关(延迟到命令确定后应用;值型配置 +/// 由 [`apply_bat_env_overrides`] 直接写入 options)。 +struct EnvModeOverrides { + watch: bool, + daemon: bool, +} + +/// 把 `BAT_*` 环境变量作为配置默认值写入 options。 +/// +/// 不标记任何 `*_explicit`(命令行参数在其后解析、总是覆盖);非法值报错 +/// 而非静默忽略,保证配置问题可诊断。 +fn apply_bat_env_overrides( + options: &mut CliOptions, + env_lookup: &impl Fn(&str) -> Option, +) -> anyhow::Result { + fn parse_env_bool(key: &str, value: &str) -> anyhow::Result { + match value.to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "0" | "false" | "no" | "off" => Ok(false), + other => Err(anyhow::anyhow!( + "环境变量 {key} 的布尔值无效:{other}(支持 1/0/true/false/yes/no/on/off)" + )), + } + } + fn parse_env_seconds(key: &str, value: &str) -> anyhow::Result { + let seconds = value + .parse::() + .map_err(|error| anyhow::anyhow!("环境变量 {key} 的秒数无效:{error}"))?; + Ok(Duration::from_secs(seconds)) + } + // 空值视为未设置:模板里保留 `BAT_XXX=` 形式的空行不产生副作用。 + let value = |key: &str| { + env_lookup(key) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }; + + if let Some(v) = value("BAT_OUTPUT") { + options.config.output_root = PathBuf::from(v); + } + if let Some(v) = value("BAT_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 { + parse_args_with_env(env::args(), |key| env::var(key).ok()) +} + +/// 测试入口:不读环境变量,解析结果只由参数决定。 +#[cfg(test)] +fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result { + parse_args_with_env(raw_args, |_| None) +} + +fn parse_args_with_env( + raw_args: impl IntoIterator, + env_lookup: impl Fn(&str) -> Option, +) -> anyhow::Result { + let mut args = raw_args.into_iter(); + let binary = args.next().unwrap_or_else(|| "bat".to_string()); + let mut options = CliOptions::default(); + // `BAT_*` 环境变量(含 .env 加载的)先作为默认值写入,不标记 explicit; + // 命令行参数随后解析,逐字段覆盖。工具/代理的"非默认"判断以本基线为准, + // 保证 status/stop/logs 在 .env 存在时不误判为显式传了同步参数。 + let env_modes = apply_bat_env_overrides(&mut options, &env_lookup)?; + options.env_baseline_config = options.config.clone(); + let mut mode_flag_from_cli = false; + + while let Some(flag) = args.next() { + match flag.as_str() { + "status" => { + ensure_command_not_set(options.command, "status")?; + options.command = CliCommand::Status; + } + "stop" => { + ensure_command_not_set(options.command, "stop")?; + options.command = CliCommand::Stop; + } + "restart" => { + ensure_command_not_set(options.command, "restart")?; + options.command = CliCommand::Restart; + } + "reload" => { + ensure_command_not_set(options.command, "reload")?; + options.command = CliCommand::Reload; + } + "refresh" => { + ensure_command_not_set(options.command, "refresh")?; + options.command = CliCommand::Refresh; + } + "verify" => { + ensure_command_not_set(options.command, "verify")?; + options.command = CliCommand::Verify; + } + "repair" => { + ensure_command_not_set(options.command, "repair")?; + options.command = CliCommand::Repair; + } + "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::() + .map_err(|error| anyhow::anyhow!("{flag} 的秒数无效:{error}"))?; + options.interval = Duration::from_secs(seconds); + options.sync_option_explicit = true; + } + "--error-retry" => { + options.error_retry_interval = + parse_duration(&next_option_value(&mut args, &flag)?)?; + options.sync_option_explicit = true; + } + "--error-retry-seconds" => { + let seconds = next_option_value(&mut args, &flag)? + .parse::() + .map_err(|error| anyhow::anyhow!("{flag} 的秒数无效:{error}"))?; + options.error_retry_interval = Duration::from_secs(seconds); + options.sync_option_explicit = true; + } + "--quiet-up-to-date" => { + options.quiet_up_to_date = true; + options.quiet_up_to_date_explicit = true; + options.sync_option_explicit = true; + } + "--no-quiet-up-to-date" => { + options.quiet_up_to_date = false; + options.quiet_up_to_date_explicit = true; + options.sync_option_explicit = true; + } + "--progress" => { + options.progress = true; + options.banner = true; + } + "--no-progress" => { + options.progress = false; + options.banner = false; + } + "--json" => { + options.output_format = OutputFormat::Json; + } + "--human" => { + options.output_format = OutputFormat::Human; + } + "--banner" => { + options.banner = true; + } + "--no-banner" => { + options.banner = false; + } + "--tail" => { + options.tail_lines = next_option_value(&mut args, &flag)? + .parse::() + .map_err(|error| anyhow::anyhow!("{flag} 的行数无效:{error}"))?; + if options.tail_lines == 0 { + return Err(anyhow::anyhow!("--tail 必须大于 0")); + } + } + "--offset" => { + options.query_offset = next_option_value(&mut args, &flag)? + .parse::() + .map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?; + options.query_option_explicit = true; + } + "--limit" => { + options.query_limit = next_option_value(&mut args, &flag)? + .parse::() + .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::() + .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::() + .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::() + .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 优先于 watch(daemon 自带 watch)。 + if matches!(options.command, CliCommand::Run) && !mode_flag_from_cli && !options.config.dry_run + { + if env_modes.daemon { + options.daemon = true; + } else if env_modes.watch { + options.watch = true; + } + } + + 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 { + let parsed = value + .parse::() + .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, + flag: &str, +) -> anyhow::Result { + args.next() + .ok_or_else(|| anyhow::anyhow!("{flag} 缺少参数值")) +} + +/// curl 支持的代理 scheme。 +const SUPPORTED_PROXY_SCHEMES: [&str; 6] = + ["http", "https", "socks4", "socks4a", "socks5", "socks5h"]; + +/// 校验显式代理 URL 的 scheme,尽早拒绝拼错的 scheme(如 `htp://`)。 +/// +/// 无 `://` 时 curl 默认按 http 处理 `host:port`,此处只要求非空;带 `://` 时 +/// scheme 必须是 curl 支持的代理协议,且代理主机部分不能为空。 +fn validate_proxy_url(url: &str) -> anyhow::Result<()> { + if let Some((scheme, rest)) = url.split_once("://") { + let scheme_lower = scheme.to_ascii_lowercase(); + if !SUPPORTED_PROXY_SCHEMES.contains(&scheme_lower.as_str()) { + return Err(anyhow::anyhow!( + "--proxy 使用了不支持的 scheme:{scheme}(支持 {})", + SUPPORTED_PROXY_SCHEMES.join("/") + )); + } + if rest.is_empty() { + return Err(anyhow::anyhow!("--proxy 缺少代理主机:{url}")); + } + } + Ok(()) +} + +fn parse_proxy_config(value: &str) -> anyhow::Result { + let normalized = value.trim(); + if normalized.is_empty() { + return Err(anyhow::anyhow!("--proxy 不能为空")); + } + + match normalized.to_ascii_lowercase().as_str() { + "auto" | "env" => Ok(CurlProxyConfig::auto()), + "none" | "direct" | "off" | "disabled" => Ok(CurlProxyConfig::disabled()), + _ => { + validate_proxy_url(normalized)?; + Ok(CurlProxyConfig::url(normalized.to_string())) + } + } +} + +fn print_usage(binary: &str) { + eprintln!("BlueArchiveToolkit official resource sync"); + eprintln!(); + eprintln!("Usage:"); + eprintln!(" {binary} [OPTIONS]"); + eprintln!(" {binary} [OPTIONS]"); + eprintln!(); + eprintln!("Commands:"); + eprintln!(" refresh Run one update check, or ask a live daemon to refresh"); + eprintln!(" verify Verify remote plan, local manifest, and official seed hashes"); + eprintln!(" repair Redownload resources that fail local verification"); + eprintln!(" 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 Use an official server-info URL"); + eprintln!(" --server-info-file Use an official server-info file name"); + eprintln!(" --server-info-path Use a local server-info JSON file"); + eprintln!(" --app-version Override app version"); + eprintln!(" --connection-group Override connection group"); + eprintln!(" --launcher-version Launcher metadata API version (default: 1.7.2)"); + eprintln!(); + eprintln!("Sync:"); + eprintln!(" --platforms Platforms, e.g. Windows,Android"); + eprintln!( + " --output Official resource publish root (default: ./bat-resources)" + ); + eprintln!( + " --localized-output 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 CAS root for official release imports"); + eprintln!(" --import-resource-db SQLite ResourceRepository path"); + eprintln!(" --snapshot Override snapshot path (default: /current/official-sync-snapshot.json)"); + eprintln!(" --curl curl executable (default: curl)"); + eprintln!( + " --download-concurrency Bounded parallel downloads (default: 8, range 1..=256)" + ); + eprintln!(" --proxy curl proxy override (default: auto from env)"); + eprintln!(" --no-proxy Force direct curl connections"); + eprintln!(" --unzip 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 Query offset for resource-index/parse-text-units/parse-errors/translation-tasks"); + eprintln!(" --limit Query limit for resource-index/parse-text-units/parse-errors/translation-tasks (1..=1000)"); + eprintln!(" --task-id Filter translation-tasks by stable task ID"); + eprintln!(" --resource-type asset_bundle, manifest, table_bundle, text_asset, media, other"); + eprintln!(" --hash Filter resource-index by full CAS hash"); + eprintln!( + " --path-pattern Filter resource-index or parse detail by path pattern" + ); + eprintln!(" --release-id Filter resource-index or translation-tasks by official release ID"); + eprintln!(" --platform Filter resource-index by metadata platform"); + eprintln!(" --destination Filter resource-index, parse detail, or translation-tasks by official destination"); + eprintln!(" --bundle-path Filter resource-index by metadata bundle path"); + eprintln!(" --archive-entry Filter resource-index, parse detail, or translation-tasks by ZIP/archive entry"); + eprintln!(" --task-status Filter translation-tasks by task status"); + eprintln!( + " --worker-status Filter translation-tasks by provider worker status" + ); + eprintln!(" --parse-status Filter resource-index or translation-tasks by parse status"); + eprintln!(" --path-id Filter parse detail by Unity object path ID"); + eprintln!(" --class-id Filter parse detail by Unity class ID"); + eprintln!(" --field-path Filter parse detail, or TypeTree field path after UnityFS field patch commands"); + eprintln!(" --format 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 Patch type for patch-apply"); + eprintln!(" --source-file Source file for patch-apply"); + eprintln!(" --patch-file Patch JSON file for patch-apply"); + eprintln!(" --bundle-file Source UnityFS bundle file"); + eprintln!(" --serialized-file Serialized file path inside UnityFS"); + eprintln!(" --object-path-id Unity object path ID for UnityFS patch"); + eprintln!( + " --string-field-path Deprecated alias for UnityFS TypeTree field path" + ); + eprintln!(" --replacement-file Replacement bytes or UTF-8 string file"); + eprintln!(" --replacement-text Inline replacement text for string-field patch"); + eprintln!( + " --replacement-json Semantic replacement, e.g. signed/enum/bit_field JSON" + ); + eprintln!(" --expected-name Expected TextAsset name"); + eprintln!(" --expected-value Expected source string value"); + eprintln!(" --expected-json Optional expected semantic source value"); + eprintln!(" --target-file Target output file written atomically"); + eprintln!(); + eprintln!("Daemon:"); + eprintln!(" --watch Run in foreground loop"); + eprintln!(" --daemon Start detached watch process"); + eprintln!(" --state-dir Daemon state dir (default: /tmp/bat-pid)"); + eprintln!(" --interval Normal check interval (default: 1h)"); + eprintln!(" --error-retry Retry interval after error (default: 60s)"); + eprintln!(" --quiet-up-to-date Suppress clean up-to-date reports"); + eprintln!(" --no-quiet-up-to-date Always print reports"); + eprintln!(" --tail Log lines for logs command (default: 200)"); + eprintln!(); + eprintln!("Output:"); + eprintln!(" --human Human-readable output (default)"); + eprintln!(" --json Stable JSON output for scripts"); + eprintln!(" --progress | --no-progress Enable/disable stderr progress logs"); + eprintln!(" --banner | --no-banner Enable/disable startup banner"); + eprintln!(" -h, --help Show this help"); + eprintln!(); + eprintln!("Defaults:"); + eprintln!(" platforms: Windows,Android"); + eprintln!( + " official resource output: ./bat-resources (current -> versions/, .staging/)" + ); + 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 { + let value = value.trim(); + if value.is_empty() { + return Err(anyhow::anyhow!("时间间隔不能为空")); + } + + let (number, multiplier) = if let Some(number) = value.strip_suffix("ms") { + let millis = number + .parse::() + .map_err(|error| anyhow::anyhow!("毫秒时间间隔无效:{value}:{error}"))?; + return Ok(Duration::from_millis(millis)); + } else if let Some(number) = value.strip_suffix('s') { + (number, 1) + } else if let Some(number) = value.strip_suffix('m') { + (number, 60) + } else if let Some(number) = value.strip_suffix('h') { + (number, 60 * 60) + } else { + (value, 1) + }; + + let amount = number + .parse::() + .map_err(|error| anyhow::anyhow!("时间间隔无效:{value}:{error}"))?; + Ok(Duration::from_secs(amount.saturating_mul(multiplier))) +} + +fn format_duration(duration: Duration) -> String { + let total_millis = duration.as_millis(); + let minutes = total_millis / 60_000; + let seconds = (total_millis / 1_000) % 60; + let millis = total_millis % 1_000; + format!("{minutes:02}:{seconds:02}.{millis:03}") +} + +fn parse_platforms(value: &str) -> Result, String> { + value + .split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(parse_platform) + .collect() +} + +fn parse_platform(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "windows" | "win" => Ok(PatchPlatform::Windows), + "android" => Ok(PatchPlatform::Android), + _ => Err(format!("不支持的平台:{value}")), + } +} + +#[cfg(test)] +#[path = "app_tests.rs"] +mod tests; diff --git a/infrastructure/src/bin/bat/app_tests.rs b/infrastructure/src/bin/bat/app_tests.rs new file mode 100644 index 0000000..dd26d3e --- /dev/null +++ b/infrastructure/src/bin/bat/app_tests.rs @@ -0,0 +1,3672 @@ +use super::*; + +fn parse(values: &[&str]) -> anyhow::Result { + parse_args_from(values.iter().map(|value| value.to_string())) +} + +fn parse_with_env(values: &[&str], env: &[(&str, &str)]) -> anyhow::Result { + let map: HashMap = env + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + parse_args_with_env(values.iter().map(|value| value.to_string()), move |key| { + map.get(key).cloned() + }) +} + +#[test] +fn env_defaults_apply_and_cli_overrides() { + assert_eq!( + CliOptions::default().config.download_concurrency, + DEFAULT_DOWNLOAD_CONCURRENCY + ); + let options = parse_with_env( + &["bat"], + &[ + ("BAT_OUTPUT", "/srv/bat"), + ("BAT_LOCALIZED_OUTPUT", "/srv/bat-localized"), + ("BAT_IMPORT_REPOSITORY", "1"), + ("BAT_IMPORT_CAS_ROOT", "/srv/bat-cas"), + ("BAT_IMPORT_RESOURCE_DB", "/srv/bat/resources.sqlite"), + ("BAT_AUTO_DISCOVER", "1"), + ("BAT_STATE_DIR", "/srv/state"), + ("BAT_INTERVAL_SECONDS", "120"), + ("BAT_DOWNLOAD_CONCURRENCY", "3"), + ], + ) + .unwrap(); + assert_eq!(options.config.output_root, PathBuf::from("/srv/bat")); + assert_eq!( + options.config.localized_output_root, + PathBuf::from("/srv/bat-localized") + ); + assert!(options.config.import_repository); + assert_eq!( + options.config.import_cas_root, + Some(PathBuf::from("/srv/bat-cas")) + ); + assert_eq!( + options.config.import_resource_repository_path, + Some(PathBuf::from("/srv/bat/resources.sqlite")) + ); + assert!(options.config.auto_discover); + assert_eq!(options.config.download_concurrency, 3); + assert_eq!(options.state_dir, PathBuf::from("/srv/state")); + assert_eq!(options.interval, Duration::from_secs(120)); + + // 命令行覆盖环境变量。 + let options = parse_with_env( + &["bat", "--output", "/cli/out"], + &[("BAT_OUTPUT", "/srv/bat")], + ) + .unwrap(); + assert_eq!(options.config.output_root, PathBuf::from("/cli/out")); + + // 空值视为未设置(模板中保留 `BAT_XXX=` 空行无副作用)。 + let options = parse_with_env(&["bat"], &[("BAT_OUTPUT", "")]).unwrap(); + assert_eq!( + options.config.output_root, + CliOptions::default().config.output_root + ); +} + +#[test] +fn env_watch_daemon_only_affect_bare_run() { + let options = parse_with_env(&["bat"], &[("BAT_WATCH", "1")]).unwrap(); + assert!(options.watch); + + // 同时设置时 daemon 优先(daemon 自带 watch)。 + let options = parse_with_env(&["bat"], &[("BAT_WATCH", "1"), ("BAT_DAEMON", "1")]).unwrap(); + assert!(options.daemon); + assert!(!options.watch); + + // 子命令不受影响:verify 内部置 dry-run,若误吃 env watch 会直接解析失败。 + let options = parse_with_env(&["bat", "verify"], &[("BAT_WATCH", "1")]).unwrap(); + assert!(!options.watch); + assert!(!options.daemon); + + // 命令行显式 --dry-run 时 env watch 让位(命令行意图优先)。 + let options = parse_with_env(&["bat", "--dry-run"], &[("BAT_WATCH", "1")]).unwrap(); + assert!(!options.watch); + + // 命令行显式选择前台 watch 时 env daemon 让位。 + let options = parse_with_env(&["bat", "--watch"], &[("BAT_DAEMON", "1")]).unwrap(); + assert!(options.watch); + assert!(!options.daemon); +} + +#[test] +fn env_values_do_not_break_status_and_reload_guard() { + // .env 提供的代理/工具/输出目录不算"显式同步参数",status 应照常可用。 + let options = parse_with_env( + &["bat", "status"], + &[ + ("BAT_PROXY", "http://127.0.0.1:7897"), + ("BAT_CURL", "/usr/bin/curl"), + ("BAT_OUTPUT", "/srv/bat"), + ], + ) + .unwrap(); + assert_eq!(options.command, CliCommand::Status); + assert!(!tools_are_non_default( + &options.config, + &options.env_baseline_config + )); + + // 命令行再改工具才算偏离基线。 + let options = parse_with_env( + &["bat", "--curl", "/opt/curl"], + &[("BAT_PROXY", "http://127.0.0.1:7897")], + ) + .unwrap(); + assert!(tools_are_non_default( + &options.config, + &options.env_baseline_config + )); +} + +#[test] +fn env_invalid_values_error() { + assert!(parse_with_env(&["bat"], &[("BAT_WATCH", "maybe")]).is_err()); + assert!(parse_with_env(&["bat"], &[("BAT_INTERVAL_SECONDS", "abc")]).is_err()); + assert!(parse_with_env(&["bat"], &[("BAT_PROXY", "ftp://x")]).is_err()); + assert!(parse_with_env(&["bat"], &[("BAT_DOWNLOAD_CONCURRENCY", "0")]).is_err()); + assert!(parse_with_env(&["bat"], &[("BAT_DOWNLOAD_CONCURRENCY", "257")]).is_err()); + assert!(parse(&["bat", "--download-concurrency", "0"]).is_err()); + assert!(parse(&["bat", "--download-concurrency", "257"]).is_err()); +} + +#[test] +fn cli_download_concurrency_is_preserved_for_daemon_child() { + let options = parse(&["bat", "--download-concurrency", "4"]).unwrap(); + assert_eq!(options.config.download_concurrency, 4); + let args = daemon_child_args(&options); + assert!(args + .windows(2) + .any(|pair| pair == ["--download-concurrency", "4"])); +} + +#[test] +fn parse_env_line_handles_quotes_and_rejects_bad_keys() { + assert_eq!( + parse_env_line("KEY=value"), + Some(("KEY".to_string(), "value".to_string())) + ); + assert_eq!( + parse_env_line("KEY=\"quoted value\""), + Some(("KEY".to_string(), "quoted value".to_string())) + ); + assert_eq!( + parse_env_line("KEY='single'"), + Some(("KEY".to_string(), "single".to_string())) + ); + assert_eq!( + parse_env_line("BAT_OUTPUT = ./x"), + Some(("BAT_OUTPUT".to_string(), "./x".to_string())) + ); + assert_eq!(parse_env_line("no_equals_sign"), None); + assert_eq!(parse_env_line("1BAD=x"), None); + assert_eq!(parse_env_line("BAD KEY=x"), None); +} + +#[test] +fn env_template_is_parseable_and_bootstrap_ready() { + // 模板每个非注释行必须可解析;无参启动所需的最小配置默认启用。 + let mut keys = Vec::new(); + for line in ENV_TEMPLATE.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (key, _) = parse_env_line(line).unwrap_or_else(|| panic!("模板行必须可解析:{line}")); + keys.push(key); + } + assert!(keys.contains(&"BAT_OUTPUT".to_string())); + assert!(keys.contains(&"BAT_LOCALIZED_OUTPUT".to_string())); + assert!(keys.contains(&"BAT_IMPORT_REPOSITORY".to_string())); + assert!(keys.contains(&"BAT_IMPORT_CAS_ROOT".to_string())); + assert!(keys.contains(&"BAT_IMPORT_RESOURCE_DB".to_string())); + assert!(keys.contains(&"BAT_AUTO_DISCOVER".to_string())); +} + +#[test] +fn task_persistence_round_trip_and_interrupt_marking() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path(); + + let (registry, summary) = TaskRegistry::with_persistence(state_dir); + assert!(summary.contains("空任务历史"), "{summary}"); + let finished_id = registry.create(TaskKind::Sync); + registry.update(&finished_id, |record| { + record.status = "running"; + record.started_at = Some(record.created_at); + }); + registry.append_log(&finished_id, "进度 1".to_string()); + registry.update(&finished_id, |record| { + record.status = "succeeded"; + record.finished_at = Some(record.created_at + 1); + record.result = Some(serde_json::json!({ "ok": true })); + }); + let running_id = registry.create(TaskKind::Verify); + registry.update(&running_id, |record| record.status = "running"); + let failed_id = registry.create(TaskKind::Refresh); + registry.update(&failed_id, |record| { + record.status = "failed"; + record.error = Some(ApiError::new( + ErrorCode::HTTP_NOT_FOUND, + "task.executor", + "404", + )); + }); + let repair_id = registry.create(TaskKind::Repair); + registry.update(&repair_id, |record| record.status = "succeeded"); + drop(registry); + + // 重启:恢复历史;running 任务标记中断;错误码经持久化往返保留。 + let (registry, summary) = TaskRegistry::with_persistence(state_dir); + assert!(summary.contains("恢复任务历史 4 条"), "{summary}"); + assert!(summary.contains("标记中断 1 条"), "{summary}"); + let finished = registry.get(&finished_id).unwrap(); + assert_eq!(finished.status, "succeeded"); + assert_eq!(finished.result, Some(serde_json::json!({ "ok": true }))); + assert_eq!( + registry.logs(&finished_id).unwrap(), + vec!["进度 1".to_string()] + ); + let interrupted = registry.get(&running_id).unwrap(); + assert_eq!(interrupted.status, "failed"); + assert_eq!( + interrupted.error.as_ref().unwrap().code(), + ErrorCode::TASK_INTERRUPTED + ); + assert!(interrupted.finished_at.is_some()); + let failed = registry.get(&failed_id).unwrap(); + assert_eq!( + failed.error.as_ref().unwrap().code(), + ErrorCode::HTTP_NOT_FOUND + ); + let repair = registry.get(&repair_id).unwrap(); + assert_eq!(repair.kind, "resource.repair"); + assert_eq!(repair.status, "succeeded"); + + // seq 持久化:重启后(本测试内 pid 相同)新任务不与历史撞 ID。 + let new_id = registry.create(TaskKind::Sync); + assert!( + [&finished_id, &running_id, &failed_id, &repair_id] + .iter() + .all(|id| **id != new_id), + "新任务 ID {new_id} 与历史撞号" + ); + assert_eq!(registry.list().len(), 5); +} + +#[test] +fn task_persistence_recovers_from_corrupt_file() { + let temp = tempfile::TempDir::new().unwrap(); + fs::write(temp.path().join(TASKS_FILE_NAME), b"not-json").unwrap(); + + let (registry, summary) = TaskRegistry::with_persistence(temp.path()); + assert!(summary.contains("任务历史不可用"), "{summary}"); + // 损坏文件改名留证,不静默覆盖。 + assert!(temp.path().join("bat-tasks.json.corrupt").exists()); + assert!(registry.list().is_empty()); + + // 恢复后可正常写入新历史。 + registry.create(TaskKind::Sync); + let bytes = fs::read(temp.path().join(TASKS_FILE_NAME)).unwrap(); + let file: PersistedTaskFile = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(file.version, TASKS_FILE_VERSION); + assert_eq!(file.tasks.len(), 1); + assert_eq!(file.tasks[0].status, "queued"); +} + +#[test] +fn task_persistence_skips_unknown_kinds() { + let temp = tempfile::TempDir::new().unwrap(); + let file = serde_json::json!({ + "version": TASKS_FILE_VERSION, + "seq": 9, + "tasks": [ + { + "id": "task-1-1", + "kind": "patch.apply", + "status": "succeeded", + "created_at": 1, + "updated_at": 2 + }, + { + "id": "task-1-2", + "kind": "resource.sync", + "status": "succeeded", + "created_at": 3, + "updated_at": 4 + } + ] + }); + fs::write( + temp.path().join(TASKS_FILE_NAME), + serde_json::to_vec(&file).unwrap(), + ) + .unwrap(); + + let (registry, summary) = TaskRegistry::with_persistence(temp.path()); + assert!(summary.contains("跳过无法识别 1 条"), "{summary}"); + assert!(registry.get("task-1-1").is_none()); + assert_eq!(registry.get("task-1-2").unwrap().status, "succeeded"); +} + +#[test] +fn parses_auto_discover_sync_args() { + let options = parse(&[ + "bat", + "--auto-discover", + "--platforms", + "Windows,Android", + "--snapshot", + "/tmp/snapshot.json", + "--import-repository", + "--import-cas-root", + "/tmp/bat-cas", + "--import-resource-db", + "/tmp/bat-resources.sqlite", + "--dry-run", + "--plan", + ]) + .unwrap(); + let config = options.config; + + assert!(config.auto_discover); + assert_eq!( + config.platforms.as_deref(), + Some([PatchPlatform::Windows, PatchPlatform::Android].as_slice()) + ); + assert_eq!( + config.snapshot_path, + Some(PathBuf::from("/tmp/snapshot.json")) + ); + assert!(config.import_repository); + assert_eq!(config.import_cas_root, Some(PathBuf::from("/tmp/bat-cas"))); + assert_eq!( + config.import_resource_repository_path, + Some(PathBuf::from("/tmp/bat-resources.sqlite")) + ); + assert!(config.dry_run); + assert!(config.plan); + assert!(config.audit_local); + assert!(config.repair); + assert!(!options.watch); + assert_eq!( + options.interval, + Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS) + ); + assert_eq!( + options.error_retry_interval, + Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS) + ); + assert!(!options.quiet_up_to_date); + assert!(!options.quiet_up_to_date_explicit); + assert!(options.progress); + assert!(options.banner); + assert_eq!(options.output_format, OutputFormat::Human); + assert_eq!(config.output_root, PathBuf::from("./bat-resources")); + assert_eq!( + config.localized_output_root, + PathBuf::from("./bat-localized") + ); + assert_eq!(options.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR)); +} + +#[test] +fn parses_explicit_source_and_disable_repair() { + let options = parse(&[ + "bat", + "--server-info-url", + "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json", + "--connection-group", + "Prod-Audit", + "--app-version", + "1.70.0", + "--no-audit-local", + "--no-repair", + "--unzip", + "/usr/bin/unzip", + ]) + .unwrap(); + let config = options.config; + + assert!(!config.auto_discover); + assert_eq!(config.connection_group.as_deref(), Some("Prod-Audit")); + assert_eq!(config.app_version.as_deref(), Some("1.70.0")); + assert!(!config.audit_local); + assert!(!config.repair); + assert_eq!(config.unzip_command, PathBuf::from("/usr/bin/unzip")); + assert!(matches!( + config.server_info_source, + Some(OfficialServerInfoSource::OfficialUrl(ref url)) + if url == "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" + )); +} + +#[test] +fn parses_proxy_options() { + let options = parse(&["bat", "--proxy", "http://127.0.0.1:7890"]).unwrap(); + assert!(options.proxy_option_explicit); + assert_eq!( + options.config.curl_proxy.mode(), + &CurlProxyMode::Url("http://127.0.0.1:7890".to_string()) + ); + + let options = parse(&["bat", "--proxy", "none"]).unwrap(); + assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Disabled); + + let options = parse(&["bat", "--no-proxy"]).unwrap(); + assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Disabled); + + let options = parse(&["bat", "--proxy", "auto"]).unwrap(); + assert!(options.proxy_option_explicit); + assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Auto); + + // socks5 等受支持 scheme 可用。 + let options = parse(&["bat", "--proxy", "socks5://127.0.0.1:1080"]).unwrap(); + assert_eq!( + options.config.curl_proxy.mode(), + &CurlProxyMode::Url("socks5://127.0.0.1:1080".to_string()) + ); +} + +#[test] +fn rejects_proxy_with_unsupported_scheme() { + let error = parse(&["bat", "--proxy", "htp://127.0.0.1:7890"]).unwrap_err(); + assert!(error.to_string().contains("不支持的 scheme")); + + // 无 scheme 的 host:port 仍接受(curl 默认按 http 处理)。 + assert!(parse(&["bat", "--proxy", "127.0.0.1:7890"]).is_ok()); +} + +#[test] +fn parses_watch_defaults_to_one_hour_and_quiet_up_to_date() { + let options = parse(&["bat", "--auto-discover", "--watch", "--interval", "30m"]).unwrap(); + + assert!(options.watch); + assert_eq!(options.interval, Duration::from_secs(30 * 60)); + assert_eq!( + options.error_retry_interval, + Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS) + ); + assert!(options.quiet_up_to_date); + assert!(!options.quiet_up_to_date_explicit); +} + +#[test] +fn parses_daemon_mode_as_background_watch() { + let options = parse(&[ + "bat", + "--auto-discover", + "--daemon", + "--output", + "/tmp/daemon-output", + "--localized-output", + "/tmp/daemon-localized", + "--interval", + "30m", + ]) + .unwrap(); + + assert_eq!(options.command, CliCommand::Run); + assert!(options.daemon); + assert!(!options.watch); + assert_eq!( + options.config.output_root, + PathBuf::from("/tmp/daemon-output") + ); + assert_eq!( + options.config.localized_output_root, + PathBuf::from("/tmp/daemon-localized") + ); + assert_eq!(options.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR)); + assert_eq!(options.interval, Duration::from_secs(30 * 60)); + assert!(options.quiet_up_to_date); +} + +#[test] +fn parses_output_format_flags() { + let options = parse(&["bat", "status", "--json"]).unwrap(); + assert_eq!(options.command, CliCommand::Status); + assert_eq!(options.output_format, OutputFormat::Json); + + let options = parse(&["bat", "status", "--json", "--human"]).unwrap(); + assert_eq!(options.output_format, OutputFormat::Human); +} + +#[test] +fn parses_status_and_stop_commands() { + let status = parse(&["bat", "status"]).unwrap(); + assert_eq!(status.command, CliCommand::Status); + assert!(!status.watch); + assert!(!status.daemon); + assert!(!status.progress); + assert!(!status.banner); + assert_eq!(status.config.output_root, PathBuf::from("./bat-resources")); + assert_eq!( + status.config.localized_output_root, + PathBuf::from("./bat-localized") + ); + assert_eq!(status.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR)); + + let stop = parse(&["bat", "stop", "--state-dir", "/tmp/custom-bat-pid"]).unwrap(); + assert_eq!(stop.command, CliCommand::Stop); + assert_eq!(stop.config.output_root, PathBuf::from("./bat-resources")); + assert_eq!( + stop.config.localized_output_root, + PathBuf::from("./bat-localized") + ); + assert_eq!(stop.state_dir, PathBuf::from("/tmp/custom-bat-pid")); +} + +#[test] +fn parses_management_and_resource_commands() { + let refresh = parse(&["bat", "refresh", "--force"]).unwrap(); + assert_eq!(refresh.command, CliCommand::Refresh); + assert!(refresh.config.auto_discover); + assert!(refresh.config.force); + + let verify = parse(&["bat", "verify"]).unwrap(); + assert_eq!(verify.command, CliCommand::Verify); + assert!(verify.config.auto_discover); + assert!(verify.config.dry_run); + assert!(verify.config.plan); + assert!(!verify.config.repair); + + let repair = parse(&["bat", "repair"]).unwrap(); + assert_eq!(repair.command, CliCommand::Repair); + assert!(repair.config.auto_discover); + + for (value, expected) in [ + ("restart", CliCommand::Restart), + ("reload", CliCommand::Reload), + ("logs", CliCommand::Logs), + ("doctor", CliCommand::Doctor), + ("clean-stable", CliCommand::CleanStable), + ("parse-status", CliCommand::ParseStatus), + ("parse-text-units", CliCommand::ParseTextUnits), + ("parse-errors", CliCommand::ParseErrors), + ("translation-tasks", CliCommand::TranslationTasks), + ("localized-status", CliCommand::LocalizedStatus), + ("resource-index", CliCommand::ResourceIndex), + ] { + let options = parse(&["bat", value]).unwrap(); + assert_eq!(options.command, expected); + } + + let index = parse(&[ + "bat", + "resource-index", + "--resource-type", + "text_asset", + "--hash", + "abc", + "--path-pattern", + "TextAssets/**", + "--release-id", + "v-current", + "--platform", + "windows", + "--destination", + "TextAssets/Scenario.json", + "--bundle-path", + "Bundles/story.bundle", + "--archive-entry", + "story/Scenario.json", + "--parse-status", + "parsed", + "--format", + "json", + "--offset", + "5", + "--limit", + "25", + ]) + .unwrap(); + assert_eq!(index.command, CliCommand::ResourceIndex); + assert_eq!(index.query_resource_type, Some(ResourceType::TextAsset)); + assert_eq!(index.query_hash.as_deref(), Some("abc")); + assert_eq!(index.query_path_pattern.as_deref(), Some("TextAssets/**")); + assert_eq!( + index.query_official_release_id.as_deref(), + Some("v-current") + ); + assert_eq!(index.query_platform.as_deref(), Some("windows")); + assert_eq!( + index.query_destination.as_deref(), + Some("TextAssets/Scenario.json") + ); + assert_eq!( + index.query_bundle_path.as_deref(), + Some("Bundles/story.bundle") + ); + assert_eq!( + index.query_archive_entry.as_deref(), + Some("story/Scenario.json") + ); + assert_eq!(index.query_parse_status.as_deref(), Some("parsed")); + assert_eq!(index.query_format.as_deref(), Some("json")); + assert_eq!(index.query_offset, 5); + assert_eq!(index.query_limit, 25); + + let text_units = parse(&[ + "bat", + "parse-text-units", + "--destination", + "Bundle/test.bundle", + "--archive-entry", + "assets/scenario.bundle", + "--path-id", + "42", + "--class-id", + "114", + "--field-path", + "Scenario.Message", + "--format", + "plain", + "--path-pattern", + "Bundle/**", + "--offset", + "2", + "--limit", + "10", + ]) + .unwrap(); + assert_eq!(text_units.command, CliCommand::ParseTextUnits); + assert_eq!( + text_units.query_destination.as_deref(), + Some("Bundle/test.bundle") + ); + assert_eq!(text_units.query_path_id, Some(42)); + assert_eq!(text_units.query_class_id, Some(114)); + assert_eq!( + text_units.query_field_path.as_deref(), + Some("Scenario.Message") + ); + assert_eq!(text_units.query_format.as_deref(), Some("plain")); + assert_eq!(text_units.query_offset, 2); + assert_eq!(text_units.query_limit, 10); + + let tasks = parse(&[ + "bat", + "translation-tasks", + "--task-id", + "textunit/v-current/TextAssets/Scenario.json", + "--release-id", + "v-current", + "--destination", + "TextAssets/Scenario.json", + "--archive-entry", + "story/Scenario.json", + "--task-status", + "skipped_parse_failed", + "--parse-status", + "failed", + "--format", + "json", + "--has-reason", + "--offset", + "1", + "--limit", + "20", + ]) + .unwrap(); + assert_eq!(tasks.command, CliCommand::TranslationTasks); + assert_eq!( + tasks.query_task_id.as_deref(), + Some("textunit/v-current/TextAssets/Scenario.json") + ); + assert_eq!( + tasks.query_official_release_id.as_deref(), + Some("v-current") + ); + assert_eq!( + tasks.query_destination.as_deref(), + Some("TextAssets/Scenario.json") + ); + assert_eq!( + tasks.query_archive_entry.as_deref(), + Some("story/Scenario.json") + ); + assert_eq!( + tasks.query_task_status.as_deref(), + Some("skipped_parse_failed") + ); + assert_eq!(tasks.query_parse_status.as_deref(), Some("failed")); + assert_eq!(tasks.query_format.as_deref(), Some("json")); + assert_eq!(tasks.query_has_reason, Some(true)); + assert_eq!(tasks.query_offset, 1); + assert_eq!(tasks.query_limit, 20); + + let error = parse(&["bat", "parse-status", "--limit", "10"]).unwrap_err(); + assert!(error.to_string().contains("查询过滤参数只适用于")); + + let error = parse(&["bat", "parse-text-units", "--hash", "abc"]).unwrap_err(); + assert!(error.to_string().contains("--resource-type/--hash")); + + let error = parse(&["bat", "resource-index", "--path-id", "42"]).unwrap_err(); + assert!(error + .to_string() + .contains("--path-id/--class-id/--field-path")); + + let error = parse(&["bat", "translation-tasks", "--resource-type", "text_asset"]).unwrap_err(); + assert!(error + .to_string() + .contains("--resource-type/--hash/--platform/--bundle-path")); +} + +#[test] +fn parses_write_patch_commands_without_output_confusion() { + let patch = parse(&[ + "bat", + "patch-apply", + "--patch-kind", + "text", + "--source-file", + "/tmp/source.txt", + "--patch-file", + "/tmp/source.patch.json", + "--target-file", + "/tmp/target.txt", + ]) + .unwrap(); + assert_eq!(patch.command, CliCommand::PatchApply); + assert_eq!(patch.patch_kind, Some(PatchApplyKind::Text)); + assert_eq!( + patch.patch_source_path, + Some(PathBuf::from("/tmp/source.txt")) + ); + + let text_asset = parse(&[ + "bat", + "unityfs-patch-text-asset", + "--bundle-file", + "/tmp/source.bundle", + "--serialized-file", + "CAB-1", + "--object-path-id", + "7", + "--replacement-file", + "/tmp/replacement.bytes", + "--expected-name", + "Scenario", + "--target-file", + "/tmp/target.bundle", + ]) + .unwrap(); + assert_eq!(text_asset.command, CliCommand::UnityFsPatchTextAsset); + assert_eq!(text_asset.unityfs_path_id, Some(7)); + assert_eq!( + text_asset.unityfs_expected_name.as_deref(), + Some("Scenario") + ); + + let string_field = parse(&[ + "bat", + "unityfs-patch-string-field", + "--bundle-file", + "/tmp/source.bundle", + "--serialized-file", + "CAB-1", + "--object-path-id", + "9", + "--string-field-path", + "entry.message", + "--replacement-text", + "老師", + "--expected-value", + "先生", + "--target-file", + "/tmp/target.bundle", + ]) + .unwrap(); + assert_eq!(string_field.command, CliCommand::UnityFsPatchStringField); + assert_eq!( + string_field.unityfs_field_path.as_deref(), + Some("entry.message") + ); + assert_eq!( + string_field.unityfs_replacement_text.as_deref(), + Some("老師") + ); + + let field = parse(&[ + "bat", + "unityfs-patch-field", + "--bundle-file", + "/tmp/source.bundle", + "--serialized-file", + "CAB-1", + "--object-path-id", + "11", + "--field-path", + "scores[1]", + "--expected-json", + r#"{"kind":"signed","value":20}"#, + "--replacement-json", + r#"{"kind":"signed","value":42}"#, + "--target-file", + "/tmp/target.bundle", + ]) + .unwrap(); + assert_eq!(field.command, CliCommand::UnityFsPatchField); + assert_eq!(field.unityfs_field_path.as_deref(), Some("scores[1]")); + assert_eq!( + field.unityfs_expected_semantic_value, + Some(UnitySerializedReplacementValue::Signed(20)) + ); + assert_eq!( + field.unityfs_replacement_value, + Some(UnitySerializedReplacementValue::Signed(42)) + ); + + let enum_field = parse(&[ + "bat", + "unityfs-patch-field", + "--bundle-file", + "/tmp/source.bundle", + "--serialized-file", + "CAB-1", + "--object-path-id", + "12", + "--field-path", + "difficulty", + "--expected-json", + r#"{"kind":"enum","value":{"type_name":"ScenarioDifficulty","storage_type":"int","value":2}}"#, + "--replacement-json", + r#"{"kind":"enum","value":{"type_name":"ScenarioDifficulty","storage_type":"int","value":3}}"#, + "--target-file", + "/tmp/target.bundle", + ]) + .unwrap(); + assert_eq!(enum_field.command, CliCommand::UnityFsPatchField); + assert_eq!( + enum_field.unityfs_expected_semantic_value, + Some(UnitySerializedReplacementValue::Enum { + type_name: "ScenarioDifficulty".to_string(), + storage_type: "int".to_string(), + value: 2, + }) + ); + assert_eq!( + enum_field.unityfs_replacement_value, + Some(UnitySerializedReplacementValue::Enum { + type_name: "ScenarioDifficulty".to_string(), + storage_type: "int".to_string(), + value: 3, + }) + ); + + let bitfield_field = parse(&[ + "bat", + "unityfs-patch-field", + "--bundle-file", + "/tmp/source.bundle", + "--serialized-file", + "CAB-1", + "--object-path-id", + "13", + "--field-path", + "target_layers", + "--expected-json", + r#"{"kind":"bit_field","value":{"type_name":"LayerMask","storage_type":"UInt32","bits":5}}"#, + "--replacement-json", + r#"{"kind":"bit_field","value":{"type_name":"LayerMask","storage_type":"UInt32","bits":9}}"#, + "--target-file", + "/tmp/target.bundle", + ]) + .unwrap(); + assert_eq!(bitfield_field.command, CliCommand::UnityFsPatchField); + assert_eq!( + bitfield_field.unityfs_expected_semantic_value, + Some(UnitySerializedReplacementValue::BitField { + type_name: "LayerMask".to_string(), + storage_type: "UInt32".to_string(), + bits: 5, + }) + ); + assert_eq!( + bitfield_field.unityfs_replacement_value, + Some(UnitySerializedReplacementValue::BitField { + type_name: "LayerMask".to_string(), + storage_type: "UInt32".to_string(), + bits: 9, + }) + ); + + let map_field = parse(&[ + "bat", + "unityfs-patch-field", + "--bundle-file", + "/tmp/source.bundle", + "--serialized-file", + "CAB-1", + "--object-path-id", + "12", + "--field-path", + "texts", + "--replacement-json", + r#"{"kind":"map","value":[{"kind":"object","value":[{"name":"first","value":{"kind":"string","value":"jp"}},{"name":"second","value":{"kind":"string","value":"こんにちは"}}]}]}"#, + "--target-file", + "/tmp/target.bundle", + ]) + .unwrap(); + assert_eq!(map_field.command, CliCommand::UnityFsPatchField); + assert_eq!(map_field.unityfs_field_path.as_deref(), Some("texts")); + assert_eq!( + map_field.unityfs_replacement_value, + Some(UnitySerializedReplacementValue::Map(vec![ + UnitySerializedReplacementValue::Object(vec![ + bat_assetbundle::UnitySerializedFieldReplacement { + name: "first".to_string(), + value: UnitySerializedReplacementValue::String("jp".to_string()), + }, + bat_assetbundle::UnitySerializedFieldReplacement { + name: "second".to_string(), + value: UnitySerializedReplacementValue::String("こんにちは".to_string()), + }, + ]), + ])) + ); + + let error = parse(&[ + "bat", + "patch-apply", + "--patch-kind", + "text", + "--source-file", + "/tmp/source.txt", + "--patch-file", + "/tmp/source.patch.json", + "--output", + "/tmp/wrong", + "--target-file", + "/tmp/target.txt", + ]) + .unwrap_err(); + assert!(error.to_string().contains("--target-file")); +} + +#[test] +fn parses_logs_tail_and_rejects_force_for_verify() { + let options = parse(&["bat", "logs", "--tail", "25"]).unwrap(); + assert_eq!(options.tail_lines, 25); + + let error = parse(&["bat", "verify", "--force"]).unwrap_err(); + assert!(error.to_string().contains("不能和 --force")); +} + +#[test] +fn restart_with_explicit_options_defaults_to_auto_discover() { + let options = parse(&["bat", "restart", "--output", "/tmp/bat-resources"]).unwrap(); + + assert_eq!(options.command, CliCommand::Restart); + assert!(options.output_explicit); + assert!(options.config.auto_discover); +} + +#[test] +fn daemon_child_args_preserve_sync_options() { + let options = parse(&[ + "bat", + "--auto-discover", + "--daemon", + "--platforms", + "Windows,Android", + "--output", + "/tmp/daemon-output", + "--localized-output", + "/tmp/daemon-localized", + "--import-repository", + "--import-cas-root", + "/tmp/daemon-cas", + "--import-resource-db", + "/tmp/daemon-resources.sqlite", + "--state-dir", + "/tmp/daemon-state", + "--curl", + "/usr/bin/curl", + "--proxy", + "http://127.0.0.1:7890", + "--unzip", + "/usr/bin/unzip", + "--interval", + "30m", + "--error-retry", + "45s", + "--json", + "--no-banner", + ]) + .unwrap(); + + let args = daemon_child_args(&options); + assert!(args.contains(&"--watch".to_string())); + assert!(args.contains(&"--daemon-child".to_string())); + assert!(!args.contains(&"--daemon".to_string())); + assert!(args + .windows(2) + .any(|pair| pair == ["--output", "/tmp/daemon-output"])); + assert!(args + .windows(2) + .any(|pair| pair == ["--localized-output", "/tmp/daemon-localized"])); + assert!(args.contains(&"--import-repository".to_string())); + assert!(args + .windows(2) + .any(|pair| pair == ["--import-cas-root", "/tmp/daemon-cas"])); + assert!(args + .windows(2) + .any(|pair| pair == ["--import-resource-db", "/tmp/daemon-resources.sqlite"])); + assert!(args + .windows(2) + .any(|pair| pair == ["--state-dir", "/tmp/daemon-state"])); + assert!(args + .windows(2) + .any(|pair| pair == ["--platforms", "Windows,Android"])); + // 代理凭据不得进入子进程 argv:只放不含凭据的 flag,URL 经环境变量下传。 + assert!(args.contains(&PROXY_FROM_ENV_FLAG.to_string())); + assert!(!args.iter().any(|arg| arg.contains("127.0.0.1:7890"))); + assert!(!args.windows(2).any(|pair| pair[0] == "--proxy")); + assert!(args.windows(2).any(|pair| pair == ["--interval", "30m"])); + assert!(args.windows(2).any(|pair| pair == ["--error-retry", "45s"])); + assert!(args.contains(&"--quiet-up-to-date".to_string())); + assert!(args.contains(&"--json".to_string())); + assert!(args.contains(&"--no-banner".to_string())); +} + +#[test] +fn curl_proxy_url_extracts_only_url_mode() { + assert_eq!( + curl_proxy_url(&CurlProxyConfig::url("http://user:secret@127.0.0.1:7890")), + Some("http://user:secret@127.0.0.1:7890".to_string()) + ); + assert_eq!(curl_proxy_url(&CurlProxyConfig::auto()), None); + assert_eq!(curl_proxy_url(&CurlProxyConfig::disabled()), None); +} + +#[test] +fn daemon_proxy_secret_round_trip_and_clear() { + let dir = tempfile::TempDir::new().unwrap(); + let state_dir = dir.path(); + + assert_eq!(read_daemon_proxy_secret(state_dir).unwrap(), None); + + write_daemon_proxy_secret(state_dir, "http://user:secret@127.0.0.1:7890").unwrap(); + assert_eq!( + read_daemon_proxy_secret(state_dir).unwrap(), + Some("http://user:secret@127.0.0.1:7890".to_string()) + ); + // 凭据文件以 0600 创建。 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(daemon_proxy_secret_path(state_dir)) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + + clear_daemon_proxy_secret(state_dir); + assert_eq!(read_daemon_proxy_secret(state_dir).unwrap(), None); +} + +// 序列化会修改进程级 PROXY_URL_ENV_VAR 的测试,避免并行测试相互干扰。 +static PROXY_ENV_TEST_LOCK: Mutex<()> = Mutex::new(()); + +#[test] +fn proxy_from_env_flag_reads_and_consumes_env_var() { + let _guard = PROXY_ENV_TEST_LOCK + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + env::set_var(PROXY_URL_ENV_VAR, "http://user:secret@127.0.0.1:7890"); + let options = parse(&["bat", "--watch", "--proxy-from-env"]).unwrap(); + assert!(options.proxy_option_explicit); + assert_eq!( + options.config.curl_proxy.mode(), + &CurlProxyMode::Url("http://user:secret@127.0.0.1:7890".to_string()) + ); + // 读取后应从环境中清除,避免被 curl 等孙进程继承。 + assert!(env::var(PROXY_URL_ENV_VAR).is_err()); +} + +#[test] +fn proxy_from_env_flag_errors_when_env_var_missing() { + let _guard = PROXY_ENV_TEST_LOCK + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + env::remove_var(PROXY_URL_ENV_VAR); + let error = parse(&["bat", "--watch", "--proxy-from-env"]).unwrap_err(); + assert!(error.to_string().contains(PROXY_URL_ENV_VAR)); +} + +fn rpc_request(method: &str, params: Option) -> JsonRpcRequest { + JsonRpcRequest { + jsonrpc: Some("2.0".to_string()), + id: Some(serde_json::json!(1)), + method: method.to_string(), + params, + } +} + +#[test] +fn canonical_rpc_method_resolves_aliases() { + assert_eq!(canonical_rpc_method("bat.status"), RPC_METHOD_STATUS); + assert_eq!(canonical_rpc_method("bat.doctor"), RPC_METHOD_DOCTOR); + assert_eq!(canonical_rpc_method("bat.refresh"), RPC_METHOD_REFRESH); + assert_eq!(canonical_rpc_method("daemon.status"), RPC_METHOD_STATUS); + assert_eq!( + canonical_rpc_method("resource.list"), + RPC_METHOD_RESOURCE_MANIFEST + ); + assert_eq!(canonical_rpc_method("resource.state"), "resource.state"); + assert_eq!(canonical_rpc_method("unknown.method"), "unknown.method"); +} + +#[test] +fn is_pending_rpc_method_covers_planned_namespaces() { + assert!(is_pending_rpc_method("patch.build")); + assert!(is_pending_rpc_method("unityfs.inspect")); + assert!(is_pending_rpc_method("task.create")); + assert!(is_pending_rpc_method("daemon.clean-stable")); + // restart、sync/verify/repair、task.cancel/logs、catalog.*、 + // resource.manifest 和文件级 patch/unityfs 写入方法已实现。 + assert!(!is_pending_rpc_method("daemon.restart")); + assert!(!is_pending_rpc_method("patch.apply")); + assert!(!is_pending_rpc_method("unityfs.patch_text_asset")); + assert!(!is_pending_rpc_method("unityfs.patch_string_field")); + assert!(!is_pending_rpc_method("unityfs.patch_field")); + assert!(!is_pending_rpc_method("resource.sync")); + assert!(!is_pending_rpc_method("resource.verify")); + assert!(!is_pending_rpc_method("resource.repair")); + assert!(!is_pending_rpc_method("resource.manifest")); + assert!(!is_pending_rpc_method("resource.list")); + assert!(!is_pending_rpc_method("parse.status")); + assert!(!is_pending_rpc_method("parse.text_units")); + assert!(!is_pending_rpc_method("parse.errors")); + assert!(!is_pending_rpc_method("localized.status")); + assert!(!is_pending_rpc_method("catalog.status")); + assert!(!is_pending_rpc_method("catalog.refresh")); + assert!(!is_pending_rpc_method("task.cancel")); + assert!(!is_pending_rpc_method("task.logs")); + assert!(!is_pending_rpc_method("daemon.status")); + assert!(!is_pending_rpc_method("daemon.doctor")); +} + +#[test] +fn dispatch_daemon_restart_starts_controller_and_requests_stop() { + let temp = tempfile::TempDir::new().unwrap(); + let control = new_daemon_control(); + let envelope = dispatch_rpc_method( + &rpc_request("daemon.restart", None), + temp.path(), + &control, + &test_task_context(), + "req-restart-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["status"], "accepted"); + assert_eq!(value["data"]["command"], "restart"); + assert_eq!(value["data"]["controller_pid"], 4242); + assert_eq!( + wait_for_daemon_wake(Some(&control), Duration::from_millis(1)), + DaemonWake::Stop + ); +} + +#[test] +fn task_registry_cancel_and_logs() { + let registry = TaskRegistry::new(); + let id = registry.create(TaskKind::Sync); + + // 取消未结束任务:置标志。 + assert!(matches!( + registry.request_cancel(&id), + CancelOutcome::Requested + )); + assert!(registry.cancel_flag(&id).unwrap().load(Ordering::Relaxed)); + + // 追加日志(超上限时丢弃最旧)。 + for i in 0..(MAX_TASK_LOG_LINES + 5) { + registry.append_log(&id, format!("line {i}")); + } + let logs = registry.logs(&id).unwrap(); + assert_eq!(logs.len(), MAX_TASK_LOG_LINES); + assert_eq!( + logs.last().unwrap(), + &format!("line {}", MAX_TASK_LOG_LINES + 4) + ); + + // 已结束任务:AlreadyFinished;不存在:NotFound。 + registry.update(&id, |record| record.status = "succeeded"); + assert!(matches!( + registry.request_cancel(&id), + CancelOutcome::AlreadyFinished + )); + assert!(matches!( + registry.request_cancel("nope"), + CancelOutcome::NotFound + )); +} + +fn test_task_context() -> DaemonTaskContext { + test_task_context_with_config(OfficialUpdateConfig::default()) +} + +fn test_task_context_with_config(base_config: OfficialUpdateConfig) -> DaemonTaskContext { + let (queue, _rx) = mpsc::channel::(); + DaemonTaskContext { + registry: TaskRegistry::new(), + queue, + base_config, + restart_controller: test_restart_controller, + } +} + +fn test_restart_controller(_state_dir: &Path) -> anyhow::Result { + Ok(4242) +} + +#[test] +fn dispatch_unknown_method_returns_unknown_error_envelope() { + let temp = tempfile::TempDir::new().unwrap(); + let control = new_daemon_control(); + let envelope = dispatch_rpc_method( + &rpc_request("nope.nope", None), + temp.path(), + &control, + &test_task_context(), + "req-test-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "BAT-ERR-700001"); + assert_eq!(value["request_id"], "req-test-1"); +} + +#[test] +fn dispatch_pending_method_returns_not_implemented_envelope() { + let temp = tempfile::TempDir::new().unwrap(); + let control = new_daemon_control(); + let envelope = dispatch_rpc_method( + &rpc_request("patch.build", None), + temp.path(), + &control, + &test_task_context(), + "req-test-2".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "BAT-ERR-700003"); +} + +#[test] +fn dispatch_patch_apply_requires_params() { + let temp = tempfile::TempDir::new().unwrap(); + let control = new_daemon_control(); + let envelope = dispatch_rpc_method( + &rpc_request("patch.apply", None), + temp.path(), + &control, + &test_task_context(), + "req-test-patch-params".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "BAT-ERR-700002"); +} + +#[test] +fn dispatch_unityfs_patch_methods_are_live_handlers_and_require_params() { + let temp = tempfile::TempDir::new().unwrap(); + let control = new_daemon_control(); + for method in [ + "unityfs.patch_text_asset", + "unityfs.patch_string_field", + "unityfs.patch_field", + ] { + let envelope = dispatch_rpc_method( + &rpc_request(method, None), + temp.path(), + &control, + &test_task_context(), + format!("req-test-{method}"), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], false, "method={method}"); + assert_eq!(value["error"]["code"], "BAT-ERR-700002", "method={method}"); + assert_eq!(value["error"]["location"], method, "method={method}"); + assert!( + value["error"]["message"] + .as_str() + .unwrap() + .contains("params 不能为空"), + "method={method}" + ); + } +} + +#[test] +fn dispatch_patch_apply_writes_text_patch_target() { + let temp = tempfile::TempDir::new().unwrap(); + let source_path = temp.path().join("source.txt"); + let patch_path = temp.path().join("patch.json"); + let target_path = temp.path().join("target.txt"); + fs::write(&source_path, "先生、こんにちは").unwrap(); + let patch = bat_patch::text::diff("先生、こんにちは", "老師、你好"); + fs::write(&patch_path, serde_json::to_vec(&patch).unwrap()).unwrap(); + + let control = new_daemon_control(); + let envelope = dispatch_rpc_method( + &rpc_request( + "patch.apply", + Some(serde_json::json!({ + "kind": "text", + "source_path": source_path, + "patch_path": patch_path, + "target_path": target_path, + })), + ), + temp.path(), + &control, + &test_task_context(), + "req-test-patch-apply".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["command"], "patch.apply"); + assert_eq!( + fs::read_to_string(temp.path().join("target.txt")).unwrap(), + "老師、你好" + ); +} + +#[test] +fn dispatch_task_list_returns_empty_ok_envelope() { + let temp = tempfile::TempDir::new().unwrap(); + let control = new_daemon_control(); + let envelope = dispatch_rpc_method( + &rpc_request("task.list", None), + temp.path(), + &control, + &test_task_context(), + "req-test-3".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["status"], "ok"); + assert_eq!(value["data"]["tasks"], serde_json::json!([])); +} + +#[test] +fn dispatch_task_status_reports_task_not_found() { + let temp = tempfile::TempDir::new().unwrap(); + let control = new_daemon_control(); + let envelope = dispatch_rpc_method( + &rpc_request("task.status", Some(serde_json::json!({ "task_id": "abc" }))), + temp.path(), + &control, + &test_task_context(), + "req-test-4".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "BAT-ERR-700004"); +} + +#[test] +fn dispatch_daemon_doctor_returns_report() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + let base_config = OfficialUpdateConfig { + output_root, + ..Default::default() + }; + let (queue, _rx) = mpsc::channel::(); + let context = DaemonTaskContext { + registry: TaskRegistry::new(), + queue, + base_config, + restart_controller: test_restart_controller, + }; + + let envelope = dispatch_rpc_method( + &rpc_request("daemon.doctor", None), + &state_dir, + &new_daemon_control(), + &context, + "req-doctor-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["status"], "ok"); + assert_eq!(value["data"]["command"], "doctor"); + assert!(value["data"]["healthy"].is_boolean()); + let checks = value["data"]["checks"].as_array().unwrap(); + assert!(checks.iter().any(|check| check["name"] == "daemon_rpc")); +} + +#[test] +fn dispatch_resource_sync_enqueues_task() { + let temp = tempfile::TempDir::new().unwrap(); + let control = new_daemon_control(); + // 保留 rx 让 send 成功(不启动 worker,任务停留在 queued)。 + let (queue, rx) = mpsc::channel::(); + let context = DaemonTaskContext { + registry: TaskRegistry::new(), + queue, + base_config: OfficialUpdateConfig::default(), + restart_controller: test_restart_controller, + }; + + let envelope = dispatch_rpc_method( + &rpc_request("resource.sync", Some(serde_json::json!({ "force": true }))), + temp.path(), + &control, + &context, + "req-test-5".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["status"], "accepted"); + let task_id = value["data"]["task_id"].as_str().unwrap().to_string(); + assert_eq!(value["data"]["kind"], "resource.sync"); + + // 注册表里应有一条 queued 任务。 + let record = context.registry.get(&task_id).unwrap(); + assert_eq!(record.status, "queued"); + assert_eq!(record.kind, "resource.sync"); + + // 作业已入队,force 已透传到派生配置。 + let job = rx.try_recv().unwrap(); + assert_eq!(job.id, task_id); + assert!(job.config.force); + assert!(!job.config.dry_run); + + // task.status 能查到这条任务。 + let status_envelope = dispatch_rpc_method( + &rpc_request( + "task.status", + Some(serde_json::json!({ "task_id": task_id })), + ), + temp.path(), + &control, + &context, + "req-test-6".to_string(), + ); + let status_value = serde_json::to_value(&status_envelope).unwrap(); + assert_eq!(status_value["ok"], true); + assert_eq!(status_value["data"]["status"], "queued"); +} + +#[test] +fn dispatch_resource_repair_enqueues_repair_task() { + let temp = tempfile::TempDir::new().unwrap(); + let control = new_daemon_control(); + // 保留 rx 让 send 成功(不启动 worker,任务停留在 queued)。 + let (queue, rx) = mpsc::channel::(); + let base_config = OfficialUpdateConfig { + force: true, + dry_run: true, + audit_local: false, + repair: false, + ..Default::default() + }; + let context = DaemonTaskContext { + registry: TaskRegistry::new(), + queue, + base_config, + restart_controller: test_restart_controller, + }; + + let envelope = dispatch_rpc_method( + &rpc_request("resource.repair", None), + temp.path(), + &control, + &context, + "req-repair-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["status"], "accepted"); + assert_eq!(value["data"]["kind"], "resource.repair"); + let task_id = value["data"]["task_id"].as_str().unwrap(); + let record = context.registry.get(task_id).unwrap(); + assert_eq!(record.kind, "resource.repair"); + assert_eq!(record.status, "queued"); + + // repair 任务强制开启本地审计+修复,但不会继承 force/dry-run。 + let job = rx.try_recv().unwrap(); + assert_eq!(job.id, task_id); + assert!(!job.config.dry_run); + assert!(job.config.audit_local); + assert!(job.config.repair); + assert!(!job.config.force); +} + +#[test] +fn task_registry_create_update_and_prune() { + let registry = TaskRegistry::new(); + let id = registry.create(TaskKind::Verify); + assert_eq!(registry.get(&id).unwrap().status, "queued"); + registry.update(&id, |record| record.status = "running"); + assert_eq!(registry.get(&id).unwrap().status, "running"); + assert_eq!(registry.list().len(), 1); + + // 超过上限时裁剪最旧的已结束任务;未结束的保留。 + for _ in 0..MAX_RETAINED_TASKS { + let finished = registry.create(TaskKind::Sync); + registry.update(&finished, |record| record.status = "succeeded"); + } + assert!(registry.list().len() <= MAX_RETAINED_TASKS); + // 运行中的任务不被裁剪。 + assert_eq!(registry.get(&id).unwrap().status, "running"); +} + +#[test] +fn parses_explicit_watch_error_retry_interval() { + let options = parse(&["bat", "--watch", "--interval", "1h", "--error-retry", "45s"]).unwrap(); + assert_eq!(options.interval, Duration::from_secs(3600)); + assert_eq!(options.error_retry_interval, Duration::from_secs(45)); + + let options = parse(&["bat", "--watch", "--error-retry-seconds", "30"]).unwrap(); + assert_eq!(options.error_retry_interval, Duration::from_secs(30)); +} + +#[test] +fn parses_progress_flags() { + let options = parse(&["bat"]).unwrap(); + assert!(options.progress); + assert!(options.banner); + + let options = parse(&["bat", "--no-progress"]).unwrap(); + assert!(!options.progress); + assert!(!options.banner); + + let options = parse(&["bat", "--no-progress", "--progress"]).unwrap(); + assert!(options.progress); + assert!(options.banner); +} + +#[test] +fn parses_banner_flags() { + let options = parse(&["bat", "--no-banner"]).unwrap(); + assert!(options.progress); + assert!(!options.banner); + + let options = parse(&["bat", "--no-progress", "--banner"]).unwrap(); + assert!(!options.progress); + assert!(options.banner); +} + +#[test] +fn startup_banner_contains_product_name() { + assert!(STARTUP_BANNER.contains("BlueArchiveToolkit")); +} + +#[test] +fn verification_summary_output_names_all_validation_types() { + let summary = OfficialVerificationSummary::new(7, 1, 2, 3); + let lines = verification_summary_lines(&summary); + + assert_eq!(lines.len(), 3); + assert!(lines[0].contains("官方 .hash 强校验")); + assert!(lines[0].contains("xxHash32")); + assert!(lines[1].contains("本地 BLAKE3 复用校验")); + assert!(lines[1].contains("需修复")); + assert!(lines[2].contains("ZIP 结构校验")); + assert!(lines[2].contains("central directory")); + + let json = serde_json::to_value(&summary).unwrap(); + assert_eq!(json["official_hash_verified_count"], 2); + assert_eq!(json["local_manifest_blake3_verified_count"], 7); + assert_eq!(json["local_manifest_repair_needed_count"], 1); + assert_eq!(json["zip_structure_verified_count"], 3); +} + +#[test] +fn daemon_rpc_download_progress_formats_as_human_text() { + let progress = serde_json::json!({ + "index": 2, + "total": 19, + "url": "https://prod-clientpatch.bluearchiveyostar.com/file.zip", + "status": "finished", + "bytes": 100, + "transferred_bytes": 100 + }); + + let formatted = format_daemon_download_progress_json(&progress).unwrap(); + + assert_eq!( + formatted, + "2/19 finished https://prod-clientpatch.bluearchiveyostar.com/file.zip" + ); + assert!(!formatted.contains('{')); +} + +#[test] +fn daemon_rpc_failed_download_progress_formats_as_human_text() { + let progress = serde_json::json!({ + "index": 3, + "total": 19, + "url": "https://prod-clientpatch.bluearchiveyostar.com/missing.zip", + "status": "failed", + "failure_kind": "http_404", + "failure_http_status": 404, + "failure_retryable": false, + "failure_attempts": 1, + "quarantined": true + }); + + let formatted = format_daemon_download_progress_json(&progress).unwrap(); + + assert_eq!( + formatted, + "3/19 failed kind=http_404 http=404 retryable=false attempts=1 quarantined=true https://prod-clientpatch.bluearchiveyostar.com/missing.zip" + ); + assert!(!formatted.contains('{')); +} + +#[test] +fn daemon_status_hides_failure_superseded_by_in_progress_version() { + let version = OfficialVersionRecord { + id: "1.70.0-current-attempt".to_string(), + app_version: "1.70.0".to_string(), + bundle_version: Some("s8tloc7lo3".to_string()), + addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_token".to_string(), + resource_root: PathBuf::from("/tmp/bat/.staging/current"), + snapshot_path: PathBuf::from("/tmp/bat/.staging/current/official-sync-snapshot.json"), + staging_path: Some(PathBuf::from("/tmp/bat/.staging/current")), + version_path: None, + started_unix_seconds: Some(100), + completed_unix_seconds: None, + }; + let older_failure = OfficialFailedVersionRecord { + version: OfficialVersionRecord { + id: "1.70.0-older-failure".to_string(), + resource_root: PathBuf::from("/tmp/bat/.staging/older"), + snapshot_path: PathBuf::from("/tmp/bat/.staging/older/official-sync-snapshot.json"), + staging_path: Some(PathBuf::from("/tmp/bat/.staging/older")), + started_unix_seconds: Some(90), + ..version.clone() + }, + error: "官方 hash 校验失败 fixture".to_string(), + failed_unix_seconds: 95, + }; + let state = OfficialVersionState { + in_progress_version: Some(version), + failed_versions: vec![older_failure], + updated_unix_seconds: 100, + ..OfficialVersionState::default() + }; + + assert!(visible_historical_failed_versions(&state).is_empty()); +} + +#[test] +fn daemon_socket_path_uses_state_dir() { + assert_eq!( + daemon_socket_path(Path::new("/tmp/custom-bat-pid")), + PathBuf::from("/tmp/custom-bat-pid/bat.sock") + ); +} + +#[cfg(unix)] +#[test] +fn active_official_resource_root_uses_current_symlink() { + use std::os::unix::fs::symlink; + + let temp = tempfile::TempDir::new().unwrap(); + let output = temp.path().join("resources"); + let version = output.join("versions").join("v1"); + fs::create_dir_all(&version).unwrap(); + symlink(Path::new("versions").join("v1"), output.join("current")).unwrap(); + + assert_eq!( + active_official_resource_root(&output).unwrap(), + lexical_absolute(&version).unwrap() + ); +} + +#[cfg(unix)] +#[test] +fn daemon_rpc_rejects_socket_symlink() { + use std::os::unix::fs::symlink; + + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + fs::create_dir_all(&state_dir).unwrap(); + let outside_socket = temp.path().join("outside.sock"); + let socket_path = daemon_socket_path(&state_dir); + symlink(&outside_socket, &socket_path).unwrap(); + + assert!(daemon_socket_path_exists(&socket_path).unwrap()); + assert!(!daemon_rpc_available(&state_dir)); + let error = daemon_rpc_call(&state_dir, RPC_METHOD_STATUS, None).unwrap_err(); + assert!(error.to_string().contains("symlink")); +} + +#[test] +fn rpc_tail_param_defaults_and_rejects_zero() { + assert_eq!(rpc_tail_param(None, 200).unwrap(), 200); + assert_eq!( + rpc_tail_param(Some(&serde_json::json!({ "tail": 25 })), 200).unwrap(), + 25 + ); + let error = rpc_tail_param(Some(&serde_json::json!({ "tail": 0 })), 200).unwrap_err(); + assert!(error.to_string().contains("大于 0")); +} + +#[test] +fn json_rpc_request_and_response_are_line_protocol_friendly() { + let request: JsonRpcRequest = serde_json::from_value(serde_json::json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "bat.refresh", + "params": { "force": true } + })) + .unwrap(); + // bat.* 是兼容别名,解析为规范的 daemon.* 方法名。 + assert_eq!(request.method, "bat.refresh"); + assert_eq!(canonical_rpc_method(&request.method), RPC_METHOD_REFRESH); + assert_eq!(rpc_bool_param(request.params.as_ref(), "force"), Some(true)); + + let response = json_rpc_result( + Some(serde_json::json!(7)), + serde_json::json!({ "ok": true }), + ); + let serialized = serde_json::to_string(&response).unwrap(); + assert!(serialized.contains("\"jsonrpc\":\"2.0\"")); + assert!(serialized.contains("\"ok\":true")); + assert!(!serialized.contains('\n')); +} + +#[test] +fn daemon_control_wake_handles_refresh_reload_and_stop() { + let control = new_daemon_control(); + daemon_control_request_refresh(&control, true); + assert_eq!( + wait_for_daemon_wake(Some(&control), Duration::from_millis(1)), + DaemonWake::Refresh { force: true } + ); + + daemon_control_request_reload(&control); + assert_eq!( + wait_for_daemon_wake(Some(&control), Duration::from_millis(1)), + DaemonWake::Reload + ); + + daemon_control_request_refresh(&control, false); + daemon_control_mark_stop_requested(&control); + daemon_control_notify_all(&control); + assert_eq!( + wait_for_daemon_wake(Some(&control), Duration::from_millis(1)), + DaemonWake::Stop + ); +} + +#[cfg(unix)] +#[test] +fn shutdown_signals_wake_watch_without_mutating_daemon_control() { + clear_shutdown_signal_request(); + let control = new_daemon_control(); + + handle_shutdown_signal(libc::SIGINT); + assert!(watch_stop_requested(Some(&control))); + assert_eq!( + wait_for_daemon_wake(Some(&control), Duration::from_secs(60)), + DaemonWake::Stop + ); + + clear_shutdown_signal_request(); + handle_shutdown_signal(libc::SIGTERM); + assert!(watch_stop_requested(Some(&control))); + assert_eq!( + wait_for_daemon_wake(Some(&control), Duration::from_secs(60)), + DaemonWake::Stop + ); + + clear_shutdown_signal_request(); + assert!(!watch_stop_requested(Some(&control))); +} + +#[test] +fn daemon_control_lock_rejects_active_owner() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + + let first = DaemonControlLock::acquire(&state_dir).unwrap(); + let second = DaemonControlLock::acquire(&state_dir).unwrap_err(); + assert!(second.to_string().contains("locked")); + drop(first); + assert!(DaemonControlLock::acquire(&state_dir).is_ok()); +} + +#[test] +fn daemon_control_lock_recovers_stale_and_corrupt_files() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + fs::create_dir_all(&state_dir).unwrap(); + let lock_path = daemon_control_lock_path(&state_dir); + + fs::write(&lock_path, "999999999").unwrap(); + let lock = DaemonControlLock::acquire(&state_dir).unwrap(); + drop(lock); + assert!(!lock_path.exists()); + + fs::write(&lock_path, "not-a-pid").unwrap(); + let lock = DaemonControlLock::acquire(&state_dir).unwrap(); + assert_eq!( + fs::read_to_string(&lock_path).unwrap(), + std::process::id().to_string() + ); + drop(lock); + assert!(!lock_path.exists()); +} + +#[cfg(unix)] +#[test] +fn daemon_control_lock_file_is_private() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let lock_path = daemon_control_lock_path(&state_dir); + let lock = DaemonControlLock::acquire(&state_dir).unwrap(); + + let mode = fs::metadata(&lock_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + drop(lock); +} + +#[cfg(unix)] +#[test] +fn daemon_status_file_is_private_and_rejects_symlink() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("resources"); + fs::create_dir_all(&state_dir).unwrap(); + fs::create_dir_all(&output_root).unwrap(); + let status_path = daemon_status_path(&state_dir); + let status = test_daemon_status_file(&state_dir, &output_root); + + write_daemon_status_file(&status_path, &status).unwrap(); + let mode = fs::metadata(&status_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + + fs::remove_file(&status_path).unwrap(); + let outside = temp.path().join("outside-status.json"); + fs::write(&outside, b"outside").unwrap(); + symlink(&outside, &status_path).unwrap(); + + let error = write_daemon_status_file(&status_path, &status).unwrap_err(); + assert!(error.to_string().contains("symlink")); + assert_eq!(fs::read(&outside).unwrap(), b"outside"); +} + +#[test] +fn daemon_status_tracks_progress_success_and_next_check() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("resources"); + fs::create_dir_all(&state_dir).unwrap(); + fs::create_dir_all(&output_root).unwrap(); + write_daemon_status_file( + &daemon_status_path(&state_dir), + &test_daemon_status_file(&state_dir, &output_root), + ) + .unwrap(); + + let mut event = OfficialUpdateProgress::new("download", "(2/19) 开始 fixture"); + event.download_index = Some(2); + event.download_total = Some(19); + event.download_url = Some("https://prod-clientpatch.bluearchiveyostar.com/file.zip".into()); + update_daemon_progress(&state_dir, &event).unwrap(); + + let report = build_daemon_status_report(&state_dir).unwrap(); + assert_eq!(report.current_stage.as_deref(), Some("download")); + assert_eq!(report.download_progress.as_ref().unwrap().index, 2); + assert_eq!(report.download_progress.as_ref().unwrap().total, 19); + + let next_check = unix_seconds_after(Duration::from_secs(30)); + update_daemon_status( + &state_dir, + DaemonStatusUpdate { + state: "sleeping", + last_update_status: Some("downloaded".to_string()), + last_error: None, + next_retry_seconds: Some(30), + last_success_unix_seconds: Some(123456), + next_check_unix_seconds: Some(next_check), + pending_scheduled_force: false, + next_forced_refresh_at: SystemTime::now() + Duration::from_secs(3600), + }, + ) + .unwrap(); + let report = build_daemon_status_report(&state_dir).unwrap(); + assert_eq!(report.last_success_unix_seconds, Some(123456)); + assert_eq!(report.next_check_unix_seconds, Some(next_check)); + assert!(report.current_stage.is_none()); + assert!(report.download_progress.is_none()); +} + +#[test] +fn structured_logger_writes_jsonl_and_rotates() { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("bat-events.jsonl"); + let mut logger = RotatingStructuredLogger::new(path.clone(), 180, 2); + let mut event = OfficialUpdateProgress::new("download", "下载进度 fixture"); + event.download_index = Some(1); + event.download_total = Some(2); + event.download_url = Some("https://prod-clientpatch.bluearchiveyostar.com/a.zip".into()); + event.download_verification = Some(OfficialResourceVerification { + expected_bytes: Some(123), + actual_bytes: 123, + expected_blake3: Some("expected-blake3".to_string()), + actual_blake3: "expected-blake3".to_string(), + zip_checked: true, + zip_structure_verified: true, + }); + event.official_hash_verification = Some(OfficialResourceHashVerification { + data_url: "https://prod-clientpatch.bluearchiveyostar.com/a.bytes".to_string(), + hash_url: "https://prod-clientpatch.bluearchiveyostar.com/a.hash".to_string(), + algorithm: bat_infrastructure::OfficialResourceHashAlgorithm::XxHash32Decimal, + expected: "2044170421".to_string(), + actual: "2044170421".to_string(), + }); + + for _ in 0..8 { + logger + .write_event(Duration::from_millis(42), &event) + .unwrap(); + } + + assert!(path.exists()); + assert!(rotated_structured_log_path(&path, 1).exists()); + let line = fs::read_to_string(&path) + .unwrap() + .lines() + .next() + .unwrap() + .to_string(); + let value: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(value["stage"], "download"); + assert_eq!(value["download"]["index"], 1); + assert_eq!(value["download"]["total"], 2); + assert_eq!(value["download"]["verification"]["actual_bytes"], 123); + assert_eq!( + value["download"]["verification"]["zip_structure_verified"], + true + ); + assert_eq!( + value["download"]["official_hash"]["algorithm"], + "xxhash32_decimal" + ); + assert_eq!(value["download"]["official_hash"]["expected"], "2044170421"); +} + +#[cfg(unix)] +#[test] +fn read_pid_file_rejects_symlink() { + use std::os::unix::fs::symlink; + + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + fs::create_dir_all(&state_dir).unwrap(); + let outside = temp.path().join("outside.pid"); + fs::write(&outside, b"123").unwrap(); + let pid_path = daemon_pid_path(&state_dir); + symlink(&outside, &pid_path).unwrap(); + + let error = read_pid_file(&pid_path).unwrap_err(); + assert!(error.to_string().contains("symlink")); +} + +#[test] +fn live_daemon_resource_conflict_detects_same_output() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("resources"); + fs::create_dir_all(&output_root).unwrap(); + write_daemon_status_file( + &daemon_status_path(&state_dir), + &test_daemon_status_file(&state_dir, &output_root), + ) + .unwrap(); + fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap(); + + let conflict = live_daemon_resource_conflict(&state_dir, &output_root) + .unwrap() + .unwrap(); + assert_eq!(conflict.pid, Some(std::process::id())); + assert_eq!( + conflict.target_root, + normalized_abs_path(&output_root).unwrap() + ); +} + +#[test] +fn live_daemon_resource_conflict_allows_different_output() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let daemon_output = temp.path().join("daemon-resources"); + let manual_output = temp.path().join("manual-resources"); + fs::create_dir_all(&daemon_output).unwrap(); + fs::create_dir_all(&manual_output).unwrap(); + write_daemon_status_file( + &daemon_status_path(&state_dir), + &test_daemon_status_file(&state_dir, &daemon_output), + ) + .unwrap(); + fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap(); + + assert!(live_daemon_resource_conflict(&state_dir, &manual_output) + .unwrap() + .is_none()); +} + +#[test] +fn direct_write_rejects_live_daemon_for_same_output() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("resources"); + fs::create_dir_all(&output_root).unwrap(); + write_daemon_status_file( + &daemon_status_path(&state_dir), + &test_daemon_status_file(&state_dir, &output_root), + ) + .unwrap(); + fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap(); + let options = CliOptions { + state_dir, + config: OfficialUpdateConfig { + output_root, + ..OfficialUpdateConfig::default() + }, + ..CliOptions::default() + }; + + let error = assert_no_live_daemon_output_conflict(&options, "repair").unwrap_err(); + assert!(error.to_string().contains("同一资源目录")); + assert!(error.to_string().contains("locked")); +} + +#[test] +fn clean_stable_removes_corrupt_pid_and_lock_files() { + let temp = tempfile::TempDir::new().unwrap(); + let output_root = temp.path().join("resources"); + let state_dir = temp.path().join("state"); + fs::create_dir_all(&output_root).unwrap(); + fs::create_dir_all(&state_dir).unwrap(); + let resource_lock = output_root.join(".official-sync.lock"); + let control_lock = daemon_control_lock_path(&state_dir); + let pid_path = daemon_pid_path(&state_dir); + fs::write(&resource_lock, "not-a-pid").unwrap(); + fs::write(&control_lock, "not-a-pid").unwrap(); + fs::write(&pid_path, "not-a-pid").unwrap(); + + let options = CliOptions { + command: CliCommand::CleanStable, + output_format: OutputFormat::Json, + state_dir, + config: OfficialUpdateConfig { + output_root, + ..OfficialUpdateConfig::default() + }, + ..CliOptions::default() + }; + run_clean_stable_command(&options).unwrap(); + + assert!(!resource_lock.exists()); + assert!(!control_lock.exists()); + assert!(!pid_path.exists()); +} + +#[test] +fn rpc_stop_wait_reports_already_stopped_without_signal() { + assert!(!wait_for_rpc_stop_or_terminate(0, Duration::from_millis(1)).unwrap()); +} + +#[test] +fn sync_command_rpc_selection_only_for_default_daemon_shape() { + let options = parse(&["bat", "refresh"]).unwrap(); + assert!(refresh_should_use_daemon_rpc(&options, "refresh")); + assert_eq!( + sync_command_rpc_method(&options, "refresh"), + Some(RPC_METHOD_REFRESH) + ); + + let options = parse(&["bat", "refresh", "--force"]).unwrap(); + assert!(refresh_should_use_daemon_rpc(&options, "refresh")); + assert_eq!( + sync_command_rpc_method(&options, "refresh"), + Some(RPC_METHOD_REFRESH) + ); + + let options = parse(&["bat", "refresh", "--output", "/tmp/other"]).unwrap(); + assert!(!refresh_should_use_daemon_rpc(&options, "refresh")); + assert_eq!(sync_command_rpc_method(&options, "refresh"), None); + + let options = parse(&["bat", "refresh", "--server-info-file", "ProdNotice.json"]).unwrap(); + assert!(!refresh_should_use_daemon_rpc(&options, "refresh")); + assert_eq!(sync_command_rpc_method(&options, "refresh"), None); + + let options = parse(&["bat", "refresh", "--import-repository"]).unwrap(); + assert_eq!(sync_command_rpc_method(&options, "refresh"), None); + + let options = parse(&[ + "bat", + "refresh", + "--import-resource-db", + "/tmp/resources.sqlite", + ]) + .unwrap(); + assert_eq!(sync_command_rpc_method(&options, "refresh"), None); + + let options = parse(&["bat", "repair"]).unwrap(); + assert!(!refresh_should_use_daemon_rpc(&options, "repair")); + assert_eq!( + sync_command_rpc_method(&options, "repair"), + Some(RPC_METHOD_RESOURCE_REPAIR) + ); + + let options = parse(&["bat", "repair", "--output", "/tmp/other"]).unwrap(); + assert_eq!(sync_command_rpc_method(&options, "repair"), None); + + let options = parse(&["bat", "repair", "--force"]).unwrap(); + assert_eq!(sync_command_rpc_method(&options, "repair"), None); +} + +#[test] +fn repair_command_uses_rpc_when_default_daemon_shape() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + fs::create_dir_all(&state_dir).unwrap(); + let seen = Arc::new(Mutex::new(Vec::<(String, Option)>::new())); + let seen_calls = Arc::clone(&seen); + + let options = parse(&[ + "bat", + "repair", + "--json", + "--state-dir", + state_dir.to_str().unwrap(), + ]) + .unwrap(); + run_sync_command_with_rpc( + &options, + "repair", + |_| true, + move |_state_dir, method, params| { + seen_calls + .lock() + .unwrap() + .push((method.to_string(), params.clone())); + Ok(serde_json::json!({ + "task_id": "task-fake-repair", + "kind": "resource.repair", + })) + }, + ) + .unwrap(); + let seen = seen.lock().unwrap(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].0, RPC_METHOD_RESOURCE_REPAIR); + assert_eq!(seen[0].1, None); +} + +#[test] +fn readonly_resource_index_command_uses_rpc_with_filters() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + fs::create_dir_all(&state_dir).unwrap(); + let seen = Arc::new(Mutex::new(Vec::<(String, Option)>::new())); + let seen_calls = Arc::clone(&seen); + let options = parse(&[ + "bat", + "resource-index", + "--json", + "--state-dir", + state_dir.to_str().unwrap(), + "--resource-type", + "asset_bundle", + "--release-id", + "v-current", + "--platform", + "windows", + "--destination", + "Bundles/story.bundle", + "--bundle-path", + "Bundles/story.bundle", + "--archive-entry", + "story/TextAsset", + "--parse-status", + "parsed", + "--format", + "json", + "--offset", + "2", + "--limit", + "3", + ]) + .unwrap(); + + run_readonly_query_command_with_rpc( + &options, + |_| true, + move |_state_dir, method, params| { + seen_calls + .lock() + .unwrap() + .push((method.to_string(), params.clone())); + Ok(serde_json::json!({ "available": true, "entries": [] })) + }, + ) + .unwrap(); + + let seen = seen.lock().unwrap(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].0, RPC_METHOD_RESOURCE_INDEX); + let params = seen[0].1.as_ref().unwrap(); + assert_eq!(params["resource_type"], "asset_bundle"); + assert_eq!(params["official_release_id"], "v-current"); + assert_eq!(params["platform"], "windows"); + assert_eq!(params["destination"], "Bundles/story.bundle"); + assert_eq!(params["bundle_path"], "Bundles/story.bundle"); + assert_eq!(params["archive_entry"], "story/TextAsset"); + assert_eq!(params["parse_status"], "parsed"); + assert_eq!(params["text_unit_format"], "json"); + assert_eq!(params["offset"], 2); + assert_eq!(params["limit"], 3); +} + +#[test] +fn readonly_translation_tasks_command_uses_rpc_with_filters() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + fs::create_dir_all(&state_dir).unwrap(); + let seen = Arc::new(Mutex::new(Vec::<(String, Option)>::new())); + let seen_calls = Arc::clone(&seen); + let options = parse(&[ + "bat", + "translation-tasks", + "--json", + "--state-dir", + state_dir.to_str().unwrap(), + "--task-id", + "textunit/v-current/Bundles/story.bundle", + "--release-id", + "v-current", + "--destination", + "Bundles/story.bundle", + "--archive-entry", + "story/TextAsset", + "--task-status", + "skipped_parse_failed", + "--parse-status", + "failed", + "--format", + "json", + "--has-reason", + "--offset", + "4", + "--limit", + "5", + ]) + .unwrap(); + + run_readonly_query_command_with_rpc( + &options, + |_| true, + move |_state_dir, method, params| { + seen_calls + .lock() + .unwrap() + .push((method.to_string(), params.clone())); + Ok(serde_json::json!({ "available": true, "entries": [] })) + }, + ) + .unwrap(); + + let seen = seen.lock().unwrap(); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].0, RPC_METHOD_TRANSLATION_TASKS); + let params = seen[0].1.as_ref().unwrap(); + assert_eq!(params["task_id"], "textunit/v-current/Bundles/story.bundle"); + assert_eq!(params["official_release_id"], "v-current"); + assert_eq!(params["destination"], "Bundles/story.bundle"); + assert_eq!(params["archive_entry"], "story/TextAsset"); + assert_eq!(params["status"], "skipped_parse_failed"); + assert_eq!(params["parse_status"], "failed"); + assert_eq!(params["text_unit_format"], "json"); + assert_eq!(params["has_reason"], true); + assert_eq!(params["offset"], 4); + assert_eq!(params["limit"], 5); +} + +#[test] +fn explicit_no_quiet_up_to_date_overrides_watch_default() { + let options = parse(&[ + "bat", + "--watch", + "--no-quiet-up-to-date", + "--interval", + "1h", + ]) + .unwrap(); + + assert!(options.watch); + assert!(!options.quiet_up_to_date); + assert!(options.quiet_up_to_date_explicit); +} + +#[test] +fn rejects_watch_dry_run_and_zero_interval() { + let error = parse(&["bat", "--watch", "--dry-run"]).unwrap_err(); + assert!(error.to_string().contains("不能和 --dry-run")); + + let error = parse(&["bat", "--daemon", "--watch"]).unwrap_err(); + assert!(error.to_string().contains("不要同时传 --watch")); + + let error = parse(&["bat", "status", "--watch"]).unwrap_err(); + assert!(error.to_string().contains("status/stop")); + + let error = parse(&["bat", "status", "--output", "/tmp/resources"]).unwrap_err(); + assert!(error.to_string().contains("--state-dir")); + + let error = parse(&["bat", "--watch", "--interval-seconds", "0"]).unwrap_err(); + assert!(error.to_string().contains("必须大于 0")); + + let error = parse(&["bat", "--watch", "--error-retry-seconds", "0"]).unwrap_err(); + assert!(error.to_string().contains("失败重试间隔")); +} + +#[test] +fn parses_duration_units() { + assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600)); + assert_eq!(parse_duration("60m").unwrap(), Duration::from_secs(3600)); + assert_eq!(parse_duration("90s").unwrap(), Duration::from_secs(90)); + assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250)); + assert_eq!(parse_duration("3600").unwrap(), Duration::from_secs(3600)); +} + +#[test] +fn formats_duration_args_for_child_process() { + assert_eq!(format_duration_arg(Duration::from_secs(3600)), "1h"); + assert_eq!(format_duration_arg(Duration::from_secs(90 * 60)), "90m"); + assert_eq!(format_duration_arg(Duration::from_secs(45)), "45s"); + assert_eq!(format_duration_arg(Duration::from_millis(250)), "250ms"); +} + +#[test] +fn scheduled_forced_refresh_uses_beijing_local_times() { + let day = 20_000; + + assert_eq!( + next_forced_refresh_at_or_after(beijing_time(day, 2, 30, 0)), + beijing_time(day, 3, 0, 0) + ); + assert_eq!( + next_forced_refresh_at_or_after(beijing_time(day, 3, 0, 0)), + beijing_time(day, 3, 0, 0) + ); + assert_eq!( + next_forced_refresh_after(beijing_time(day, 3, 0, 0)), + beijing_time(day, 16, 0, 0) + ); + assert_eq!( + next_forced_refresh_after(beijing_time(day, 18, 0, 0)), + beijing_time(day + 1, 3, 0, 0) + ); + assert_eq!( + duration_until( + next_forced_refresh_at_or_after(beijing_time(day, 17, 45, 0)), + beijing_time(day, 17, 45, 0) + ), + Duration::from_secs(15 * 60) + ); +} + +#[test] +fn formats_progress_durations() { + assert_eq!(format_duration(Duration::from_millis(7)), "00:00.007"); + assert_eq!(format_duration(Duration::from_millis(65_432)), "01:05.432"); +} + +#[test] +fn quiet_up_to_date_suppresses_only_clean_reports() { + assert!(!should_print_status(OfficialUpdateStatus::UpToDate, true)); + assert!(should_print_status(OfficialUpdateStatus::Downloaded, true)); + assert!(should_print_status( + OfficialUpdateStatus::WouldDownload, + true + )); + assert!(should_print_status(OfficialUpdateStatus::UpToDate, false)); +} + +#[test] +fn status_string_is_stable() { + assert_eq!(OfficialUpdateStatus::UpToDate.as_str(), "up_to_date"); + assert_eq!( + OfficialUpdateStatus::WouldDownload.as_str(), + "would_download" + ); + assert_eq!(OfficialUpdateStatus::Downloaded.as_str(), "downloaded"); +} + +#[test] +fn redacts_proxy_credentials_in_surfaced_command() { + let command = vec![ + "/opt/bat".to_string(), + "--auto-discover".to_string(), + "--proxy".to_string(), + "http://user:secret@127.0.0.1:7890".to_string(), + "--output".to_string(), + "/var/lib/bat".to_string(), + ]; + + let redacted = redact_command_proxy_credentials(command); + + assert_eq!(redacted[3], "http://@127.0.0.1:7890"); + assert!(!redacted.join(" ").contains("secret")); + // 非代理参数保持原样。 + assert_eq!(redacted[1], "--auto-discover"); + assert_eq!(redacted[5], "/var/lib/bat"); +} + +#[test] +fn redacts_schemeless_proxy_credentials_in_surfaced_command() { + let command = vec![ + "/opt/bat".to_string(), + "--proxy".to_string(), + "user:secret@127.0.0.1:7890".to_string(), + ]; + + let redacted = redact_command_proxy_credentials(command); + + assert_eq!(redacted[2], "@127.0.0.1:7890"); + assert!(!redacted.join(" ").contains("secret")); +} + +#[test] +fn leaves_command_without_proxy_untouched() { + let command = vec![ + "/opt/bat".to_string(), + "--auto-discover".to_string(), + "--proxy".to_string(), + "auto".to_string(), + ]; + + let redacted = redact_command_proxy_credentials(command.clone()); + + assert_eq!(redacted, command); +} + +fn beijing_time(day: u64, hour: u64, minute: u64, second: u64) -> SystemTime { + system_time_from_beijing_local_seconds( + day.saturating_mul(SECONDS_PER_DAY) + .saturating_add(hour.saturating_mul(60 * 60)) + .saturating_add(minute.saturating_mul(60)) + .saturating_add(second), + ) +} + +fn test_daemon_status_file(state_dir: &Path, output_root: &Path) -> DaemonStatusFile { + test_daemon_status_file_with_localized( + state_dir, + output_root, + &output_root.with_file_name("localized"), + ) +} + +fn test_daemon_status_file_with_localized( + state_dir: &Path, + output_root: &Path, + localized_output_root: &Path, +) -> DaemonStatusFile { + DaemonStatusFile { + version: DAEMON_STATUS_VERSION, + pid: std::process::id(), + state: "sleeping".to_string(), + resource_output_root: output_root.to_path_buf(), + localized_output_root: Some(localized_output_root.to_path_buf()), + state_dir: state_dir.to_path_buf(), + log_path: daemon_log_path(state_dir), + structured_log_path: Some(daemon_structured_log_path(state_dir)), + started_unix_seconds: unix_seconds_now(), + updated_unix_seconds: unix_seconds_now(), + last_success_unix_seconds: Some(unix_seconds_now()), + next_check_unix_seconds: Some(unix_seconds_after(Duration::from_secs(60))), + last_update_status: Some("up_to_date".to_string()), + last_error: None, + next_retry_seconds: Some(60), + current_stage: None, + status_code: Some(ReleaseFlowStatusCode::OfficialUpToDate.as_str().to_string()), + current_message: None, + download_progress: None, + pending_scheduled_force: false, + next_forced_refresh_unix_seconds: None, + command: vec!["bat".to_string(), "--daemon-child".to_string()], + } +} + +/// 写入 catalog 只读查询所需的完整 fixture:daemon 状态文件、版本目录内的 +/// snapshot、版本状态文件。返回当前版本目录路径。 +fn write_catalog_fixture( + state_dir: &Path, + output_root: &Path, + current_bundle: &str, + previous_bundle: Option<&str>, +) -> PathBuf { + fs::create_dir_all(state_dir).unwrap(); + fs::create_dir_all(output_root).unwrap(); + write_daemon_status_file( + &daemon_status_path(state_dir), + &test_daemon_status_file(state_dir, output_root), + ) + .unwrap(); + + let make_version = |id: &str, bundle: &str| -> OfficialVersionRecord { + let version_dir = output_root.join("versions").join(id); + fs::create_dir_all(&version_dir).unwrap(); + let snapshot_path = version_dir.join("official-sync-snapshot.json"); + let snapshot = OfficialUpdateSnapshot { + snapshot_version: bat_infrastructure::official_update::OFFICIAL_UPDATE_SNAPSHOT_VERSION, + connection_group_name: "Prod-Audit".to_string(), + app_version: "1.70.0".to_string(), + bundle_version: Some(bundle.to_string()), + addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture" + .to_string(), + endpoints: Vec::new(), + endpoint_markers: Vec::new(), + launcher_metadata: None, + game_main_config_bootstrap: Some(bat_infrastructure::GameMainConfigSnapshot { + server_info_data_url: Some( + "https://prod-serverinfo.bluearchiveyostar.com/server-info.json".to_string(), + ), + default_connection_group: Some("Prod-Audit".to_string()), + }), + }; + bat_infrastructure::write_snapshot(&snapshot_path, &snapshot).unwrap(); + OfficialVersionRecord { + id: id.to_string(), + app_version: "1.70.0".to_string(), + bundle_version: Some(bundle.to_string()), + addressables_root: snapshot.addressables_root.clone(), + resource_root: version_dir.clone(), + snapshot_path, + staging_path: None, + version_path: Some(version_dir.clone()), + started_unix_seconds: None, + completed_unix_seconds: Some(unix_seconds_now()), + } + }; + + let current = make_version("v-current", current_bundle); + let current_dir = current.resource_root.clone(); + let state = OfficialVersionState { + current_completed_version: Some(current), + previous_available_version: previous_bundle + .map(|bundle| make_version("v-previous", bundle)), + ..OfficialVersionState::default() + }; + bat_infrastructure::write_version_state( + &output_root.join("official-version-state.json"), + &state, + ) + .unwrap(); + current_dir +} + +#[test] +fn dispatch_resource_state_reports_status_code_for_bat_api() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + + let envelope = dispatch_rpc_method( + &rpc_request("resource.state", None), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-state-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["status"], "up_to_date"); + assert_eq!(value["data"]["status_code"], "official.up_to_date"); + assert_eq!(value["data"]["status_phase"], "official_sync"); + assert_eq!(value["data"]["status_terminal"], true); + assert_eq!(value["data"]["last_update_status"], "up_to_date"); +} + +#[test] +fn dispatch_catalog_status_unavailable_without_state() { + let temp = tempfile::TempDir::new().unwrap(); + let envelope = dispatch_rpc_method( + &rpc_request("catalog.status", None), + temp.path(), + &new_daemon_control(), + &test_task_context(), + "req-cat-0".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], false); + assert_eq!(value["data"]["status"], "unavailable"); + assert_eq!(value["data"]["status_code"], "official.unavailable"); + assert_eq!( + value["data"]["distribution_status_code"], + "distribution.blocked" + ); +} + +#[test] +fn dispatch_catalog_status_reads_current_snapshot() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b2", Some("bundle-b1")); + + let envelope = dispatch_rpc_method( + &rpc_request("catalog.status", None), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-cat-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["bundle_version"], "bundle-b2"); + assert_eq!(value["data"]["status"], "published"); + assert_eq!(value["data"]["status_code"], "official.published"); + assert_eq!( + value["data"]["distribution_status_code"], + "distribution.ready" + ); + assert_eq!(value["data"]["version"]["id"], "v-current"); + assert_eq!(value["data"]["endpoint_count"], 0); + assert_eq!(value["data"]["connection_group_name"], "Prod-Audit"); + assert_eq!( + value["data"]["game_main_config_bootstrap"]["default_connection_group"], + "Prod-Audit" + ); + assert!(!value["data"] + .as_object() + .unwrap() + .contains_key("game_main_config")); +} + +#[test] +fn dispatch_catalog_versions_lists_history() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b2", Some("bundle-b1")); + + let envelope = dispatch_rpc_method( + &rpc_request("catalog.versions", None), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-cat-2".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["current"]["id"], "v-current"); + assert_eq!(value["data"]["previous"]["id"], "v-previous"); + assert_eq!(value["data"]["in_progress"], serde_json::Value::Null); + assert!(value["data"]["failed"].as_array().unwrap().is_empty()); +} + +#[test] +fn dispatch_catalog_diff_reports_bundle_change() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b2", Some("bundle-b1")); + + let envelope = dispatch_rpc_method( + &rpc_request("catalog.diff", None), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-cat-3".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["status"], "completed"); + assert_eq!(value["data"]["status_code"], "parse.completed"); + assert_eq!( + value["data"]["translation_status_code"], + "translation.queued_offline" + ); + assert_eq!(value["data"]["current_version_id"], "v-current"); + assert_eq!(value["data"]["previous_version_id"], "v-previous"); + assert_eq!(value["data"]["previous_snapshot_missing"], false); + assert_eq!(value["data"]["base_delta"]["is_initial"], false); + assert_eq!(value["data"]["base_delta"]["bundle_version_changed"], true); + assert_eq!( + value["data"]["base_delta"]["addressables_root_changed"], + false + ); +} + +#[test] +fn dispatch_catalog_diff_initial_without_previous() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None); + + let envelope = dispatch_rpc_method( + &rpc_request("catalog.diff", None), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-cat-4".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!( + value["data"]["previous_version_id"], + serde_json::Value::Null + ); + assert_eq!(value["data"]["base_delta"]["is_initial"], true); +} + +#[test] +fn dispatch_catalog_refresh_enqueues_task() { + let temp = tempfile::TempDir::new().unwrap(); + // 保留 rx 让 send 成功(不启动 worker,任务停留在 queued)。 + let (queue, rx) = mpsc::channel::(); + let tasks = DaemonTaskContext { + registry: TaskRegistry::new(), + queue, + base_config: OfficialUpdateConfig::default(), + restart_controller: test_restart_controller, + }; + let envelope = dispatch_rpc_method( + &rpc_request("catalog.refresh", None), + temp.path(), + &new_daemon_control(), + &tasks, + "req-cat-5".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["status"], "accepted"); + assert_eq!(value["data"]["kind"], "catalog.refresh"); + let task_id = value["data"]["task_id"].as_str().unwrap(); + let record = tasks.registry.get(task_id).unwrap(); + assert_eq!(record.kind, "catalog.refresh"); + assert_eq!(record.status, "queued"); + + // 作业配置是 dry-run 计划检查,不下载不审计。 + let job = rx.try_recv().unwrap(); + assert_eq!(job.id, task_id); + assert!(job.config.dry_run); + assert!(job.config.plan); + assert!(!job.config.audit_local); +} + +#[test] +fn catalog_refresh_config_is_dry_run_plan_only() { + let base = OfficialUpdateConfig::default(); + let config = TaskKind::Refresh.build_config(&base, false); + assert!(config.dry_run); + assert!(config.plan); + assert!(!config.audit_local); + assert!(!config.repair); + assert!(!config.force); + let forced = TaskKind::Refresh.build_config(&base, true); + assert!(forced.force); +} + +#[test] +fn dispatch_resource_manifest_paginates() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None); + let manifest = serde_json::json!({ + "version": 1, + "entries": { + "https://prod-clientpatch.bluearchiveyostar.com/a": { + "url": "https://prod-clientpatch.bluearchiveyostar.com/a", + "destination": "a", + "bytes": 1, + "blake3": "aa", + }, + "https://prod-clientpatch.bluearchiveyostar.com/b": { + "url": "https://prod-clientpatch.bluearchiveyostar.com/b", + "destination": "b", + "bytes": 2, + "blake3": "bb", + }, + "https://prod-clientpatch.bluearchiveyostar.com/c": { + "url": "https://prod-clientpatch.bluearchiveyostar.com/c", + "destination": "c", + "bytes": 3, + "blake3": "cc", + }, + }, + }); + fs::write( + current_dir.join("official-download-manifest.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + + let envelope = dispatch_rpc_method( + &rpc_request( + "resource.manifest", + Some(serde_json::json!({ "offset": 1, "limit": 2 })), + ), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-man-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["total_entries"], 3); + assert_eq!(value["data"]["offset"], 1); + let entries = value["data"]["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["destination"], "b"); + assert_eq!(entries[1]["destination"], "c"); + + // 非法 limit → 参数错误。 + let envelope = dispatch_rpc_method( + &rpc_request("resource.manifest", Some(serde_json::json!({ "limit": 0 }))), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-man-2".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "BAT-ERR-700002"); + + let envelope = dispatch_rpc_method( + &rpc_request( + "resource.list", + Some(serde_json::json!({ "offset": 2, "limit": 1 })), + ), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-man-3".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + let entries = value["data"]["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["destination"], "c"); +} + +fn write_resource_index_fixture(repository_path: &Path) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let repository = SqliteResourceRepository::new(repository_path) + .await + .unwrap(); + for resource in [ + test_resource( + "official/a", + "TableBundles/a.bytes", + ResourceType::TableBundle, + ), + test_resource("official/b", "Bundles/b.bundle", ResourceType::AssetBundle), + test_resource("official/c", "TextAssets/c.json", ResourceType::TextAsset), + ] { + repository.add(resource).await.unwrap(); + } + }); +} + +fn test_resource(id: &str, path: &str, resource_type: ResourceType) -> Resource { + let metadata = if id == "official/c" { + bat_core::domain::ResourceMetadata { + official_release_id: Some("v-current".to_string()), + platform: Some("windows".to_string()), + bundle_path: Some(path.to_string()), + archive_entries: vec!["story/Scenario.json".to_string()], + parse_statuses: vec!["parsed".to_string()], + text_assets: vec!["Scenario".to_string()], + text_unit_count: 4, + text_unit_formats: vec!["json".to_string()], + ..bat_core::domain::ResourceMetadata::default() + } + } else { + bat_core::domain::ResourceMetadata::default() + }; + Resource { + id: id.to_string(), + local_path: PathBuf::from(path), + entry: bat_core::domain::ResourceEntry { + path: path.to_string(), + hash: format!("{id}-hash"), + size: 10, + resource_type, + address: None, + dependencies: Vec::new(), + crc: None, + }, + metadata, + } +} + +#[test] +fn dispatch_resource_index_reports_unavailable_without_database() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + let repository_path = temp.path().join("missing/resources.sqlite"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None); + let context = test_task_context_with_config(OfficialUpdateConfig { + import_repository: true, + import_resource_repository_path: Some(repository_path.clone()), + ..OfficialUpdateConfig::default() + }); + + let envelope = dispatch_rpc_method( + &rpc_request("resource.index", None), + &state_dir, + &new_daemon_control(), + &context, + "req-index-0".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], false); + assert_eq!( + value["data"]["repository_path"].as_str().unwrap(), + repository_path.to_str().unwrap() + ); + assert!(!repository_path.exists()); +} + +#[test] +fn dispatch_resource_index_filters_and_paginates_repository() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + let repository_path = temp.path().join("resources.sqlite"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None); + write_resource_index_fixture(&repository_path); + let context = test_task_context_with_config(OfficialUpdateConfig { + import_repository: true, + import_resource_repository_path: Some(repository_path.clone()), + ..OfficialUpdateConfig::default() + }); + + let envelope = dispatch_rpc_method( + &rpc_request( + "resource.index", + Some(serde_json::json!({ + "resource_type": "text_asset", + "release_id": "v-current", + "platform": "windows", + "destination": "TextAssets/c.json", + "bundle_path": "TextAssets/c.json", + "archive_entry": "story/Scenario.json", + "parse_status": "parsed", + "format": "json", + "offset": 0, + "limit": 10 + })), + ), + &state_dir, + &new_daemon_control(), + &context, + "req-index-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["current_version_id"], "v-current"); + assert_eq!( + value["data"]["repository_path"].as_str().unwrap(), + repository_path.to_str().unwrap() + ); + assert_eq!(value["data"]["total_entries"], 1); + let entries = value["data"]["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["id"], "official/c"); + assert_eq!(entries[0]["entry"]["resource_type"], "TextAsset"); + assert_eq!(entries[0]["metadata"]["official_release_id"], "v-current"); + assert_eq!(entries[0]["metadata"]["platform"], "windows"); + assert_eq!( + entries[0]["metadata"]["archive_entries"][0], + "story/Scenario.json" + ); + assert_eq!(entries[0]["metadata"]["parse_statuses"][0], "parsed"); + assert_eq!(entries[0]["metadata"]["text_assets"][0], "Scenario"); + assert_eq!(entries[0]["metadata"]["text_unit_count"], 4); + assert_eq!(value["data"]["query"]["official_release_id"], "v-current"); + assert_eq!(value["data"]["query"]["platform"], "windows"); + assert_eq!(value["data"]["query"]["destination"], "TextAssets/c.json"); + assert_eq!(value["data"]["query"]["bundle_path"], "TextAssets/c.json"); + assert_eq!( + value["data"]["query"]["archive_entry"], + "story/Scenario.json" + ); + assert_eq!(value["data"]["query"]["parse_status"], "parsed"); + assert_eq!(value["data"]["query"]["text_unit_format"], "json"); + + let envelope = dispatch_rpc_method( + &rpc_request( + "resource.index", + Some(serde_json::json!({ "resource_type": "unknown" })), + ), + &state_dir, + &new_daemon_control(), + &context, + "req-index-2".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "BAT-ERR-700002"); +} + +#[test] +fn dispatch_parse_status_reads_current_cache_summary() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + let cache = bat_infrastructure::OfficialParseCache { + version: bat_infrastructure::OFFICIAL_PARSE_CACHE_VERSION, + generated_unix_seconds: 42, + summary: bat_infrastructure::OfficialParseSummary { + manifest_entry_count: 3, + cache_entry_count: 3, + parsed_bundle_count: 2, + text_unit_count: 7, + ..bat_infrastructure::OfficialParseSummary::default() + }, + entries: std::collections::BTreeMap::new(), + }; + fs::write( + current_dir.join(OFFICIAL_PARSE_CACHE_FILE), + serde_json::to_vec(&cache).unwrap(), + ) + .unwrap(); + let textunit_queue = bat_infrastructure::OfficialTextUnitTaskQueue { + queue_version: bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION, + official_release_id: "v-current".to_string(), + previous_release_id: Some("v-previous".to_string()), + generated_unix_seconds: 43, + current_resource_root: current_dir.clone(), + summary: bat_infrastructure::OfficialTextUnitTaskSummary { + resource_candidate_count: 2, + parse_entry_count: 2, + queued_task_count: 1, + text_unit_count: 5, + ..bat_infrastructure::OfficialTextUnitTaskSummary::default() + }, + tasks: Vec::new(), + }; + bat_infrastructure::write_textunit_task_queue_at(¤t_dir, &textunit_queue).unwrap(); + write_textunit_index_fixture(¤t_dir); + + let envelope = dispatch_rpc_method( + &rpc_request("parse.status", None), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-parse-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["current_version_id"], "v-current"); + assert_eq!(value["data"]["generated_unix_seconds"], 42); + assert_eq!(value["data"]["summary"]["text_unit_count"], 7); + assert_eq!(value["data"]["textunit_queue_available"], true); + assert_eq!( + value["data"]["textunit_task_summary"]["queued_task_count"], + 1 + ); + assert_eq!(value["data"]["textunit_task_summary"]["text_unit_count"], 5); + assert_eq!(value["data"]["textunit_index_available"], true); + assert_eq!(value["data"]["textunit_index_summary"]["unit_count"], 2); +} + +fn write_textunit_task_queue_fixture(current_dir: &Path) { + let queue = bat_infrastructure::OfficialTextUnitTaskQueue { + queue_version: bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION, + official_release_id: "v-current".to_string(), + previous_release_id: Some("v-previous".to_string()), + generated_unix_seconds: 43, + current_resource_root: current_dir.to_path_buf(), + summary: bat_infrastructure::OfficialTextUnitTaskSummary { + resource_candidate_count: 2, + parse_entry_count: 2, + queued_task_count: 1, + skipped_parse_failed_count: 1, + text_unit_count: 5, + ..bat_infrastructure::OfficialTextUnitTaskSummary::default() + }, + tasks: vec![ + bat_infrastructure::OfficialTextUnitTask { + task_id: "textunit/v-current/Bundle/a.bundle".to_string(), + official_release_id: "v-current".to_string(), + destination: "Bundle/a.bundle".to_string(), + change_kind: bat_infrastructure::OfficialResourceChangeKind::Added, + url: "https://example.invalid/a.bundle".to_string(), + bytes: 10, + blake3: "hash-a".to_string(), + parse_entry_key: Some("direct:a".to_string()), + archive_entry: None, + source_kind: Some(bat_infrastructure::OfficialParseSourceKind::DirectBundle), + parse_status: Some(bat_infrastructure::OfficialParseStatus::Parsed), + text_asset_count: 1, + text_assets: vec!["Scenario".to_string()], + text_unit_count: 5, + text_unit_formats: vec!["plain".to_string()], + text_unit_error_count: 0, + status: bat_infrastructure::OfficialTextUnitTaskStatus::QueuedOffline, + reason: None, + }, + bat_infrastructure::OfficialTextUnitTask { + task_id: "textunit/v-current/Bundle/b.bundle#assets/b.bundle".to_string(), + official_release_id: "v-current".to_string(), + destination: "Bundle/b.bundle".to_string(), + change_kind: bat_infrastructure::OfficialResourceChangeKind::Modified, + url: "https://example.invalid/b.bundle".to_string(), + bytes: 20, + blake3: "hash-b".to_string(), + parse_entry_key: Some("zip:b".to_string()), + archive_entry: Some("assets/b.bundle".to_string()), + source_kind: Some(bat_infrastructure::OfficialParseSourceKind::ZipEntry), + parse_status: Some(bat_infrastructure::OfficialParseStatus::Failed), + text_asset_count: 0, + text_assets: Vec::new(), + text_unit_count: 0, + text_unit_formats: Vec::new(), + text_unit_error_count: 0, + status: bat_infrastructure::OfficialTextUnitTaskStatus::SkippedParseFailed, + reason: Some("parser failed at serialized object table".to_string()), + }, + ], + }; + bat_infrastructure::write_textunit_task_queue_at(current_dir, &queue).unwrap(); +} + +fn write_translation_task_repository_fixture(current_dir: &Path) { + let queue = bat_infrastructure::read_textunit_task_queue_at(current_dir) + .unwrap() + .unwrap(); + let repository_path = + bat_infrastructure::SqliteTranslationTaskRepository::repository_path(current_dir); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let repository = bat_infrastructure::SqliteTranslationTaskRepository::new(&repository_path) + .await + .unwrap(); + repository.sync_queue(&queue).await.unwrap(); + repository + .update_status( + "textunit/v-current/Bundle/a.bundle", + bat_infrastructure::TranslationTaskStatus::Failed, + Some("Crowdin provider rejected the payload".to_string()), + Some("crowdin-run-1".to_string()), + ) + .await + .unwrap(); + }); +} + +fn write_textunit_index_fixture(current_dir: &Path) { + let index = bat_infrastructure::OfficialTextUnitIndex { + version: bat_infrastructure::OFFICIAL_TEXTUNIT_INDEX_VERSION, + generated_unix_seconds: 44, + resource_root: current_dir.to_path_buf(), + summary: bat_infrastructure::OfficialTextUnitIndexSummary { + unit_count: 2, + error_count: 1, + skipped_binary_text_asset_count: 0, + }, + units: vec![ + bat_infrastructure::OfficialTextUnitIndexUnit { + id: "direct:a#unit:0".to_string(), + parse_entry_key: "direct:a".to_string(), + source_url: "https://example.invalid/a.bundle".to_string(), + destination: "Bundle/a.bundle".to_string(), + archive_entry: None, + source_kind: bat_infrastructure::OfficialParseSourceKind::DirectBundle, + unity_version: Some("2021.3.56f2".to_string()), + source_text: "こんにちは".to_string(), + serialized_file: Some("CAB-a".to_string()), + path_id: Some(42), + class_id: Some(114), + field_path: Some("Scenario.Message".to_string()), + field_offset: Some(16), + field_byte_size: Some(20), + format: Some("plain".to_string()), + text_source_kind: Some("TypeTreeField".to_string()), + asset_name: None, + context: std::collections::BTreeMap::from([( + "source_kind".to_string(), + "TypeTreeField".to_string(), + )]), + }, + bat_infrastructure::OfficialTextUnitIndexUnit { + id: "direct:b#unit:0".to_string(), + parse_entry_key: "direct:b".to_string(), + source_url: "https://example.invalid/b.bundle".to_string(), + destination: "Bundle/b.bundle".to_string(), + archive_entry: Some("assets/b.bundle".to_string()), + source_kind: bat_infrastructure::OfficialParseSourceKind::ZipEntry, + unity_version: Some("2021.3.56f2".to_string()), + source_text: "{\"text\":\"hello\"}".to_string(), + serialized_file: Some("CAB-b".to_string()), + path_id: Some(7), + class_id: Some(49), + field_path: Some("TextAsset".to_string()), + field_offset: None, + field_byte_size: None, + format: Some("json".to_string()), + text_source_kind: Some("TextAsset".to_string()), + asset_name: Some("Scenario".to_string()), + context: std::collections::BTreeMap::from([ + ("source_kind".to_string(), "TextAsset".to_string()), + ("format".to_string(), "json".to_string()), + ]), + }, + ], + errors: vec![bat_infrastructure::OfficialTextUnitIndexError { + id: "direct:a#extract-error:0".to_string(), + parse_entry_key: "direct:a".to_string(), + source_url: "https://example.invalid/a.bundle".to_string(), + destination: "Bundle/a.bundle".to_string(), + archive_entry: None, + source_kind: bat_infrastructure::OfficialParseSourceKind::DirectBundle, + status: bat_infrastructure::OfficialParseStatus::Parsed, + serialized_file: Some("CAB-a".to_string()), + path_id: Some(42), + class_id: Some(114), + field_path: Some("Managed.Ref".to_string()), + offset: Some(64), + error: "managed reference TypeTree node is not decoded yet".to_string(), + }], + }; + bat_infrastructure::write_textunit_index_at(current_dir, &index).unwrap(); +} + +#[test] +fn dispatch_translation_tasks_filters_current_queue() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + write_textunit_task_queue_fixture(¤t_dir); + write_translation_task_repository_fixture(¤t_dir); + + let envelope = dispatch_rpc_method( + &rpc_request( + "translation.tasks", + Some(serde_json::json!({ + "release_id": "v-current", + "path_pattern": "Bundle/*.bundle", + "archive_entry": "assets/b.bundle", + "status": "skipped_parse_failed", + "parse_status": "failed", + "has_reason": true, + "offset": 0, + "limit": 10 + })), + ), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-translation-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["current_version_id"], "v-current"); + assert_eq!(value["data"]["summary"]["queued_task_count"], 1); + assert_eq!(value["data"]["summary"]["skipped_parse_failed_count"], 1); + assert_eq!(value["data"]["total_entries"], 1); + assert_eq!(value["data"]["query"]["official_release_id"], "v-current"); + assert_eq!(value["data"]["query"]["status"], "skipped_parse_failed"); + assert_eq!(value["data"]["query"]["parse_status"], "failed"); + assert_eq!(value["data"]["query"]["has_reason"], true); + let entries = value["data"]["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["destination"], "Bundle/b.bundle"); + assert_eq!(entries[0]["archive_entry"], "assets/b.bundle"); + assert_eq!(entries[0]["status"], "skipped_parse_failed"); + assert_eq!( + entries[0]["reason"], + "parser failed at serialized object table" + ); + + let envelope = dispatch_rpc_method( + &rpc_request( + "translation.tasks", + Some(serde_json::json!({ + "task_id": "textunit/v-current/Bundle/a.bundle", + "status": "queued_offline", + "format": "plain", + "has_reason": false, + "offset": 0, + "limit": 10 + })), + ), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-translation-2".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["total_entries"], 1); + assert_eq!(value["data"]["entries"][0]["status"], "queued_offline"); + assert_eq!( + value["data"]["entries"][0]["reason"], + serde_json::Value::Null + ); + + let envelope = dispatch_rpc_method( + &rpc_request( + "translation.tasks", + Some(serde_json::json!({ + "worker_status": "failed", + "has_failure_reason": true, + "offset": 0, + "limit": 10 + })), + ), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-translation-3".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["task_repository_available"], true); + assert_eq!(value["data"]["total_entries"], 1); + assert_eq!(value["data"]["entries"][0]["task_status"], "failed"); + assert_eq!( + value["data"]["entries"][0]["failure_reason"], + "Crowdin provider rejected the payload" + ); + assert_eq!(value["data"]["entries"][0]["attempt_count"], 0); + + let envelope = dispatch_rpc_method( + &rpc_request( + "translation.task.update", + Some(serde_json::json!({ + "task_id": "textunit/v-current/Bundle/a.bundle", + "status": "completed", + "provider_run_id": "crowdin-run-2" + })), + ), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-translation-4".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["entry"]["task_status"], "completed"); + assert_eq!( + value["data"]["entry"]["failure_reason"], + serde_json::Value::Null + ); + assert_eq!(value["data"]["entry"]["provider_run_id"], "crowdin-run-2"); + assert!(value["data"]["entry"]["completed_unix_seconds"].is_number()); + + let envelope = dispatch_rpc_method( + &rpc_request("translation.handoff", None), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-translation-handoff".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!( + value["data"]["handoff_schema_version"], + bat_infrastructure::TRANSLATION_HANDOFF_SCHEMA_VERSION + ); + assert_eq!( + value["data"]["handoff"]["units"].as_array().unwrap().len(), + 2 + ); + assert_eq!(value["data"]["handoff"]["units"][0]["status"], "translated"); + assert_eq!( + value["data"]["handoff"]["provider_runs"][0]["provider_run_id"], + "crowdin-run-2" + ); +} + +#[test] +fn dispatch_parse_text_units_filters_current_index() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + write_textunit_index_fixture(¤t_dir); + + let envelope = dispatch_rpc_method( + &rpc_request( + "parse.text_units", + Some(serde_json::json!({ + "path_pattern": "Bundle/*.bundle", + "path_id": 42, + "class_id": 114, + "field_path": "Scenario.Message", + "format": "plain", + "offset": 0, + "limit": 10 + })), + ), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-text-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["total_entries"], 1); + assert_eq!(value["data"]["entries"][0]["source_text"], "こんにちは"); + assert_eq!(value["data"]["entries"][0]["class_id"], 114); + assert_eq!(value["data"]["entries"][0]["field_offset"], 16); +} + +#[test] +fn dispatch_parse_errors_filters_current_index() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + write_textunit_index_fixture(¤t_dir); + + let envelope = dispatch_rpc_method( + &rpc_request( + "parse.errors", + Some(serde_json::json!({ + "destination": "Bundle/a.bundle", + "path_id": "42", + "class_id": "114" + })), + ), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-text-err-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["total_entries"], 1); + assert!(value["data"]["entries"][0]["error"] + .as_str() + .unwrap() + .contains("managed reference")); + assert_eq!(value["data"]["entries"][0]["offset"], 64); +} + +#[test] +fn dispatch_parse_status_reports_unavailable_without_cache() { + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + + let envelope = dispatch_rpc_method( + &rpc_request("parse.status", None), + &state_dir, + &new_daemon_control(), + &test_task_context(), + "req-parse-2".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], false); + assert_eq!(value["data"]["status"], "pending"); + assert_eq!(value["data"]["status_code"], "parse.pending"); + assert_eq!( + value["data"]["translation_status_code"], + "translation.unavailable" + ); + assert_eq!(value["data"]["current_version_id"], "v-current"); +} + +#[cfg(unix)] +#[test] +fn dispatch_localized_status_verifies_current_release_pointer() { + use std::os::unix::fs::symlink; + + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + let localized_root = temp.path().join("localized"); + let localized_version = localized_root + .join(LOCALIZED_VERSIONS_DIR) + .join("v-current"); + fs::create_dir_all(&localized_version).unwrap(); + symlink( + Path::new(LOCALIZED_VERSIONS_DIR).join("v-current"), + localized_root.join(LOCALIZED_CURRENT_LINK), + ) + .unwrap(); + fs::write( + localized_root.join(LOCALIZED_VERSION_STATE_FILE), + serde_json::to_vec(&bat_infrastructure::LocalizedVersionState { + state_version: 1, + official_release_id: "v-current".to_string(), + current_release_id: Some("v-current".to_string()), + status: "localized".to_string(), + updated_unix_seconds: 123, + }) + .unwrap(), + ) + .unwrap(); + fs::write( + localized_version.join(LOCALIZED_PATCH_MANIFEST_FILE), + serde_json::to_vec(&bat_infrastructure::LocalizedPatchManifest { + manifest_version: bat_infrastructure::LOCALIZED_PATCH_MANIFEST_VERSION, + official_release_id: "v-current".to_string(), + localized_release_id: "v-current".to_string(), + generated_unix_seconds: 124, + file_count: 0, + text_asset_operation_count: 0, + files: Vec::new(), + rollback: bat_infrastructure::LocalizedPatchRollbackInfo { + previous_current_target: None, + remove_version_path: localized_version.clone(), + }, + }) + .unwrap(), + ) + .unwrap(); + let tasks = test_task_context_with_config(OfficialUpdateConfig { + localized_output_root: localized_root.clone(), + ..OfficialUpdateConfig::default() + }); + + let envelope = dispatch_rpc_method( + &rpc_request("localized.status", None), + &state_dir, + &new_daemon_control(), + &tasks, + "req-loc-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["status"], "published"); + assert_eq!(value["data"]["status_code"], "localized.published"); + assert_eq!(value["data"]["localized_release_status"], "localized"); + assert_eq!(value["data"]["official_current_version_id"], "v-current"); + assert_eq!(value["data"]["matches_current_official_release"], true); + assert_eq!(value["data"]["current_points_to_published_version"], true); + assert_eq!(value["data"]["patch_manifest_available"], true); + assert_eq!(value["data"]["patch_manifest_matches_release"], true); + assert_eq!(value["data"]["patch_file_count"], 0); + assert_eq!(value["data"]["patch_text_asset_operation_count"], 0); + assert_eq!( + value["data"]["published_version_path"].as_str().unwrap(), + localized_version.to_string_lossy() + ); +} + +#[cfg(unix)] +#[test] +fn dispatch_localized_status_rejects_stale_release_state() { + use std::os::unix::fs::symlink; + + let temp = tempfile::TempDir::new().unwrap(); + let state_dir = temp.path().join("state"); + let output_root = temp.path().join("output"); + write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); + let localized_root = temp.path().join("localized"); + let localized_version = localized_root.join(LOCALIZED_VERSIONS_DIR).join("v-old"); + fs::create_dir_all(&localized_version).unwrap(); + symlink( + Path::new(LOCALIZED_VERSIONS_DIR).join("v-old"), + localized_root.join(LOCALIZED_CURRENT_LINK), + ) + .unwrap(); + fs::write( + localized_root.join(LOCALIZED_VERSION_STATE_FILE), + serde_json::to_vec(&bat_infrastructure::LocalizedVersionState { + state_version: 1, + official_release_id: "v-old".to_string(), + current_release_id: Some("v-old".to_string()), + status: "localized".to_string(), + updated_unix_seconds: 123, + }) + .unwrap(), + ) + .unwrap(); + let tasks = test_task_context_with_config(OfficialUpdateConfig { + localized_output_root: localized_root, + ..OfficialUpdateConfig::default() + }); + + let envelope = dispatch_rpc_method( + &rpc_request("localized.status", None), + &state_dir, + &new_daemon_control(), + &tasks, + "req-loc-2".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["data"]["available"], true); + assert_eq!(value["data"]["status"], "stale"); + assert_eq!(value["data"]["status_code"], "localized.stale"); + assert_eq!(value["data"]["localized_release_status"], "not_localized"); + assert_eq!(value["data"]["matches_current_official_release"], false); + assert_eq!( + value["data"]["published_version_path"], + serde_json::Value::Null + ); +} + +#[test] +fn dispatch_daemon_clean_stable_reports_not_implemented() { + let temp = tempfile::TempDir::new().unwrap(); + let envelope = dispatch_rpc_method( + &rpc_request("daemon.clean-stable", None), + temp.path(), + &new_daemon_control(), + &test_task_context(), + "req-clean-1".to_string(), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "BAT-ERR-700003"); +} diff --git a/infrastructure/src/bin/bat/patch_commands.rs b/infrastructure/src/bin/bat/patch_commands.rs new file mode 100644 index 0000000..515843e --- /dev/null +++ b/infrastructure/src/bin/bat/patch_commands.rs @@ -0,0 +1,199 @@ +pub(super) fn run_write_patch_command(options: &CliOptions) -> anyhow::Result<()> { + match options.command { + CliCommand::PatchApply => { + let params = patch_apply_params_from_options(options)?; + let report = apply_patch_file(¶ms)?; + print_report(options.output_format, &report) + } + CliCommand::UnityFsPatchTextAsset => { + let params = unityfs_text_asset_params_from_options(options)?; + let report = apply_unityfs_text_asset_patch_file(¶ms)?; + print_report(options.output_format, &report) + } + CliCommand::UnityFsPatchStringField => { + let params = unityfs_string_field_params_from_options(options)?; + let report = apply_unityfs_string_field_patch_file(¶ms)?; + print_report(options.output_format, &report) + } + CliCommand::UnityFsPatchField => { + let params = unityfs_field_params_from_options(options)?; + let report = apply_unityfs_field_patch_file(¶ms)?; + print_report(options.output_format, &report) + } + _ => Err(anyhow::anyhow!("不是写入 patch 命令")), + } +} + +pub(super) fn is_write_patch_command(command: CliCommand) -> bool { + matches!( + command, + CliCommand::PatchApply + | CliCommand::UnityFsPatchTextAsset + | CliCommand::UnityFsPatchStringField + | CliCommand::UnityFsPatchField + ) +} + +pub(super) fn validate_write_patch_options(options: &CliOptions) -> anyhow::Result<()> { + match options.command { + CliCommand::PatchApply => { + let _ = patch_apply_params_from_options(options)?; + reject_unityfs_write_options(options, "patch-apply")?; + } + CliCommand::UnityFsPatchTextAsset => { + let _ = unityfs_text_asset_params_from_options(options)?; + reject_patch_apply_options(options, "unityfs-patch-text-asset")?; + if options.unityfs_field_path.is_some() + || options.unityfs_replacement_text.is_some() + || options.unityfs_expected_value.is_some() + { + return Err(anyhow::anyhow!( + "unityfs-patch-text-asset 不接受 --field-path、--string-field-path、--replacement-text 或 --expected-value" + )); + } + } + CliCommand::UnityFsPatchStringField => { + let _ = unityfs_string_field_params_from_options(options)?; + reject_patch_apply_options(options, "unityfs-patch-string-field")?; + if options.unityfs_expected_name.is_some() { + return Err(anyhow::anyhow!( + "unityfs-patch-string-field 不接受 --expected-name" + )); + } + } + CliCommand::UnityFsPatchField => { + let _ = unityfs_field_params_from_options(options)?; + reject_patch_apply_options(options, "unityfs-patch-field")?; + if options.unityfs_expected_name.is_some() + || options.unityfs_replacement_text.is_some() + || options.unityfs_expected_value.is_some() + { + return Err(anyhow::anyhow!( + "unityfs-patch-field 不接受 --expected-name、--replacement-text 或 --expected-value;请使用 --replacement-json / --expected-json" + )); + } + } + _ => {} + } + Ok(()) +} + +fn patch_apply_params_from_options(options: &CliOptions) -> anyhow::Result { + Ok(PatchApplyParams { + kind: require_cli_option(options.patch_kind, "--patch-kind")?, + source_path: require_cli_option(options.patch_source_path.clone(), "--source-file")?, + patch_path: require_cli_option(options.patch_patch_path.clone(), "--patch-file")?, + target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?, + }) +} + +fn unityfs_text_asset_params_from_options( + options: &CliOptions, +) -> anyhow::Result { + Ok(UnityFsTextAssetPatchParams { + bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?, + serialized_file_path: require_cli_option( + options.unityfs_serialized_file_path.clone(), + "--serialized-file", + )?, + path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?, + replacement_path: require_cli_option( + options.unityfs_replacement_path.clone(), + "--replacement-file", + )?, + target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?, + expected_name: options.unityfs_expected_name.clone(), + }) +} + +fn unityfs_string_field_params_from_options( + options: &CliOptions, +) -> anyhow::Result { + let has_replacement_text = options.unityfs_replacement_text.is_some(); + let has_replacement_path = options.unityfs_replacement_path.is_some(); + if has_replacement_text == has_replacement_path { + return Err(anyhow::anyhow!( + "unityfs-patch-string-field 必须且只能指定 --replacement-text 或 --replacement-file 其中一个" + )); + } + Ok(UnityFsStringFieldPatchParams { + bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?, + serialized_file_path: require_cli_option( + options.unityfs_serialized_file_path.clone(), + "--serialized-file", + )?, + path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?, + field_path: require_cli_option( + options.unityfs_field_path.clone(), + "--field-path/--string-field-path", + )?, + replacement_text: options.unityfs_replacement_text.clone(), + replacement_path: options.unityfs_replacement_path.clone(), + target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?, + expected_value: options.unityfs_expected_value.clone(), + }) +} + +fn unityfs_field_params_from_options( + options: &CliOptions, +) -> anyhow::Result { + if options.unityfs_replacement_path.is_some() { + return Err(anyhow::anyhow!( + "unityfs-patch-field 不接受 --replacement-file;请使用 --replacement-json" + )); + } + Ok(UnityFsFieldPatchParams { + bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?, + serialized_file_path: require_cli_option( + options.unityfs_serialized_file_path.clone(), + "--serialized-file", + )?, + path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?, + field_path: require_cli_option( + options.unityfs_field_path.clone(), + "--field-path/--string-field-path", + )?, + replacement: require_cli_option( + options.unityfs_replacement_value.clone(), + "--replacement-json", + )?, + target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?, + expected_value: options.unityfs_expected_semantic_value.clone(), + }) +} + +fn require_cli_option(value: Option, name: &str) -> anyhow::Result { + value.ok_or_else(|| anyhow::anyhow!("缺少必要参数 {name}")) +} + +fn reject_patch_apply_options(options: &CliOptions, command: &str) -> anyhow::Result<()> { + if options.patch_kind.is_some() + || options.patch_source_path.is_some() + || options.patch_patch_path.is_some() + { + return Err(anyhow::anyhow!( + "{command} 不接受 --patch-kind、--source-file 或 --patch-file" + )); + } + Ok(()) +} + +fn reject_unityfs_write_options(options: &CliOptions, command: &str) -> anyhow::Result<()> { + if options.unityfs_bundle_path.is_some() + || options.unityfs_serialized_file_path.is_some() + || options.unityfs_path_id.is_some() + || options.unityfs_field_path.is_some() + || options.unityfs_replacement_path.is_some() + || options.unityfs_replacement_text.is_some() + || options.unityfs_expected_name.is_some() + || options.unityfs_expected_value.is_some() + || options.unityfs_replacement_value.is_some() + || options.unityfs_expected_semantic_value.is_some() + { + return Err(anyhow::anyhow!( + "{command} 不接受 UnityFS 写入参数;请改用 unityfs-patch-* 命令" + )); + } + Ok(()) +} +use super::*; diff --git a/infrastructure/src/bin/bat/readonly_query.rs b/infrastructure/src/bin/bat/readonly_query.rs new file mode 100644 index 0000000..8dafe0d --- /dev/null +++ b/infrastructure/src/bin/bat/readonly_query.rs @@ -0,0 +1,271 @@ +use super::*; + +pub(super) fn run_readonly_query_command(options: &CliOptions) -> anyhow::Result<()> { + run_readonly_query_command_with_rpc(options, daemon_rpc_available, daemon_rpc_call) +} + +pub(super) fn run_readonly_query_command_with_rpc( + options: &CliOptions, + rpc_available: impl Fn(&Path) -> bool, + rpc_call: impl Fn(&Path, &str, Option) -> anyhow::Result, +) -> anyhow::Result<()> { + let method = readonly_query_rpc_method(options.command) + .ok_or_else(|| anyhow::anyhow!("不是只读查询命令"))?; + if rpc_available(&options.state_dir) && !readonly_query_requires_local_config(options) { + let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; + let report = rpc_call( + &options.state_dir, + method, + readonly_query_rpc_params(options), + )?; + print_json_value(options.output_format, &report)?; + return Ok(()); + } + + let report = build_readonly_query_report(options, method)?; + print_json_value(options.output_format, &report) +} + +fn readonly_query_rpc_method(command: CliCommand) -> Option<&'static str> { + match command { + CliCommand::ParseStatus => Some(RPC_METHOD_PARSE_STATUS), + CliCommand::ParseTextUnits => Some(RPC_METHOD_PARSE_TEXT_UNITS), + CliCommand::ParseErrors => Some(RPC_METHOD_PARSE_ERRORS), + CliCommand::TranslationTasks => Some(RPC_METHOD_TRANSLATION_TASKS), + CliCommand::TranslationHandoff => Some(RPC_METHOD_TRANSLATION_HANDOFF), + CliCommand::LocalizedStatus => Some(RPC_METHOD_LOCALIZED_STATUS), + CliCommand::ResourceIndex => Some(RPC_METHOD_RESOURCE_INDEX), + _ => None, + } +} + +fn readonly_query_rpc_params(options: &CliOptions) -> Option { + let mut params = serde_json::Map::new(); + match options.command { + CliCommand::ResourceIndex + | CliCommand::ParseTextUnits + | CliCommand::ParseErrors + | CliCommand::TranslationTasks => { + params.insert( + "offset".to_string(), + serde_json::json!(options.query_offset), + ); + params.insert("limit".to_string(), serde_json::json!(options.query_limit)); + } + _ => return None, + } + match options.command { + CliCommand::ResourceIndex => { + if let Some(resource_type) = options.query_resource_type { + params.insert( + "resource_type".to_string(), + serde_json::json!(resource_type_rpc_label(resource_type)), + ); + } + if let Some(hash) = options.query_hash.as_ref() { + params.insert("hash".to_string(), serde_json::json!(hash)); + } + if let Some(path_pattern) = options.query_path_pattern.as_ref() { + params.insert("path_pattern".to_string(), serde_json::json!(path_pattern)); + } + if let Some(release_id) = options.query_official_release_id.as_ref() { + params.insert( + "official_release_id".to_string(), + serde_json::json!(release_id), + ); + } + if let Some(platform) = options.query_platform.as_ref() { + params.insert("platform".to_string(), serde_json::json!(platform)); + } + if let Some(destination) = options.query_destination.as_ref() { + params.insert("destination".to_string(), serde_json::json!(destination)); + } + if let Some(bundle_path) = options.query_bundle_path.as_ref() { + params.insert("bundle_path".to_string(), serde_json::json!(bundle_path)); + } + if let Some(archive_entry) = options.query_archive_entry.as_ref() { + params.insert( + "archive_entry".to_string(), + serde_json::json!(archive_entry), + ); + } + if let Some(parse_status) = options.query_parse_status.as_ref() { + params.insert("parse_status".to_string(), serde_json::json!(parse_status)); + } + if let Some(format) = options.query_format.as_ref() { + params.insert("text_unit_format".to_string(), serde_json::json!(format)); + } + } + CliCommand::ParseTextUnits | CliCommand::ParseErrors => { + if let Some(destination) = options.query_destination.as_ref() { + params.insert("destination".to_string(), serde_json::json!(destination)); + } + if let Some(path_pattern) = options.query_path_pattern.as_ref() { + params.insert("path_pattern".to_string(), serde_json::json!(path_pattern)); + } + if let Some(archive_entry) = options.query_archive_entry.as_ref() { + params.insert( + "archive_entry".to_string(), + serde_json::json!(archive_entry), + ); + } + if let Some(path_id) = options.query_path_id { + params.insert("path_id".to_string(), serde_json::json!(path_id)); + } + if let Some(class_id) = options.query_class_id { + params.insert("class_id".to_string(), serde_json::json!(class_id)); + } + if let Some(field_path) = options.query_field_path.as_ref() { + params.insert("field_path".to_string(), serde_json::json!(field_path)); + } + if let Some(format) = options.query_format.as_ref() { + params.insert("format".to_string(), serde_json::json!(format)); + } + } + CliCommand::TranslationTasks => { + if let Some(task_id) = options.query_task_id.as_ref() { + params.insert("task_id".to_string(), serde_json::json!(task_id)); + } + if let Some(release_id) = options.query_official_release_id.as_ref() { + params.insert( + "official_release_id".to_string(), + serde_json::json!(release_id), + ); + } + if let Some(destination) = options.query_destination.as_ref() { + params.insert("destination".to_string(), serde_json::json!(destination)); + } + if let Some(path_pattern) = options.query_path_pattern.as_ref() { + params.insert("path_pattern".to_string(), serde_json::json!(path_pattern)); + } + if let Some(archive_entry) = options.query_archive_entry.as_ref() { + params.insert( + "archive_entry".to_string(), + serde_json::json!(archive_entry), + ); + } + if let Some(status) = options.query_task_status.as_ref() { + params.insert("status".to_string(), serde_json::json!(status)); + } + if let Some(status) = options.query_worker_status.as_ref() { + params.insert("worker_status".to_string(), serde_json::json!(status)); + } + if let Some(parse_status) = options.query_parse_status.as_ref() { + params.insert("parse_status".to_string(), serde_json::json!(parse_status)); + } + if let Some(format) = options.query_format.as_ref() { + params.insert("text_unit_format".to_string(), serde_json::json!(format)); + } + if let Some(has_reason) = options.query_has_reason { + params.insert("has_reason".to_string(), serde_json::json!(has_reason)); + } + if let Some(has_failure_reason) = options.query_has_failure_reason { + params.insert( + "has_failure_reason".to_string(), + serde_json::json!(has_failure_reason), + ); + } + } + _ => {} + } + Some(serde_json::Value::Object(params)) +} + +fn readonly_query_requires_local_config(options: &CliOptions) -> bool { + matches!(options.command, CliCommand::ResourceIndex) + && options.config.import_resource_repository_path.is_some() +} + +fn build_readonly_query_report( + options: &CliOptions, + method: &str, +) -> anyhow::Result { + match method { + RPC_METHOD_PARSE_STATUS => build_parse_status_report(&options.state_dir), + RPC_METHOD_PARSE_TEXT_UNITS => build_parse_text_units_report( + &options.state_dir, + textunit_query_from_options(options), + options.query_offset, + options.query_limit, + ), + RPC_METHOD_PARSE_ERRORS => build_parse_errors_report( + &options.state_dir, + textunit_query_from_options(options), + options.query_offset, + options.query_limit, + ), + RPC_METHOD_TRANSLATION_TASKS => build_translation_tasks_report( + &options.state_dir, + translation_task_query_from_options(options), + options.query_offset, + options.query_limit, + ), + RPC_METHOD_TRANSLATION_HANDOFF => build_translation_handoff_report(&options.state_dir), + RPC_METHOD_LOCALIZED_STATUS => { + build_localized_status_report(&options.state_dir, &options.config) + } + RPC_METHOD_RESOURCE_INDEX => build_resource_index_report( + &options.state_dir, + &options.config, + resource_index_query_from_options(options), + options.query_offset, + options.query_limit, + ), + _ => Err(anyhow::anyhow!("不支持的只读查询方法:{method}")), + } +} + +pub(super) fn validate_readonly_query_options(options: &CliOptions) -> anyhow::Result<()> { + let has_resource_index_only_filter = options.query_resource_type.is_some() + || options.query_hash.is_some() + || options.query_platform.is_some() + || options.query_bundle_path.is_some(); + let has_parse_object_filter = options.query_path_id.is_some() + || options.query_class_id.is_some() + || options.query_field_path.is_some(); + let has_translation_task_filter = options.query_task_id.is_some() + || options.query_task_status.is_some() + || options.query_worker_status.is_some() + || options.query_has_reason.is_some() + || options.query_has_failure_reason.is_some(); + + match options.command { + CliCommand::ResourceIndex => { + if has_parse_object_filter || has_translation_task_filter { + return Err(anyhow::anyhow!( + "--path-id/--class-id/--field-path 只适用于 parse-text-units 或 parse-errors;--task-id/--task-status/--worker-status/--has-reason/--has-failure-reason 只适用于 translation-tasks" + )); + } + } + CliCommand::ParseTextUnits | CliCommand::ParseErrors => { + if has_resource_index_only_filter + || has_translation_task_filter + || options.query_official_release_id.is_some() + || options.query_parse_status.is_some() + { + return Err(anyhow::anyhow!( + "--resource-type/--hash/--release-id/--platform/--bundle-path/--parse-status 只适用于 resource-index 或 translation-tasks;--task-id/--task-status/--worker-status/--has-reason/--has-failure-reason 只适用于 translation-tasks" + )); + } + } + CliCommand::TranslationTasks => { + if has_resource_index_only_filter || has_parse_object_filter { + return Err(anyhow::anyhow!( + "--resource-type/--hash/--platform/--bundle-path 只适用于 resource-index;--path-id/--class-id/--field-path 只适用于 parse-text-units 或 parse-errors" + )); + } + } + CliCommand::TranslationHandoff if options.query_option_explicit => { + return Err(anyhow::anyhow!( + "translation-handoff 不接受查询过滤参数;请使用 translation-tasks 查询单项任务" + )); + } + CliCommand::ParseStatus | CliCommand::LocalizedStatus if options.query_option_explicit => { + return Err(anyhow::anyhow!( + "查询过滤参数只适用于 resource-index、parse-text-units、parse-errors 或 translation-tasks" + )); + } + _ => {} + } + Ok(()) +} diff --git a/infrastructure/src/bin/bat/task_registry.rs b/infrastructure/src/bin/bat/task_registry.rs new file mode 100644 index 0000000..50cc467 --- /dev/null +++ b/infrastructure/src/bin/bat/task_registry.rs @@ -0,0 +1,587 @@ +use super::*; + +pub(super) const MAX_RETAINED_TASKS: usize = 64; +/// 每个任务保留的进度日志行数上限。 +pub(super) const MAX_TASK_LOG_LINES: usize = 200; + +/// 任务类型:目前覆盖官方同步、校验与 catalog 更新检查。 +#[derive(Debug, Clone, Copy)] +pub(super) enum TaskKind { + Sync, + Verify, + Repair, + /// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。 + Refresh, +} + +impl TaskKind { + pub(super) fn method(self) -> &'static str { + match self { + Self::Sync => RPC_METHOD_RESOURCE_SYNC, + Self::Verify => RPC_METHOD_RESOURCE_VERIFY, + Self::Repair => RPC_METHOD_RESOURCE_REPAIR, + Self::Refresh => RPC_METHOD_CATALOG_REFRESH, + } + } + + /// 由 daemon 基准配置派生该任务的实际同步配置。 + pub(super) fn build_config( + self, + base: &OfficialUpdateConfig, + force: bool, + ) -> OfficialUpdateConfig { + let mut config = base.clone(); + match self { + Self::Sync => { + config.dry_run = false; + config.force = config.force || force; + } + Self::Verify => { + config.dry_run = true; + config.plan = true; + config.audit_local = true; + config.repair = false; + config.force = false; + } + Self::Repair => { + config.dry_run = false; + config.audit_local = true; + config.repair = true; + config.force = false; + } + Self::Refresh => { + config.dry_run = true; + config.plan = true; + config.audit_local = false; + config.repair = false; + config.force = force; + } + } + config + } +} + +/// 请求取消任务的结果。 +pub(super) enum CancelOutcome { + Requested, + AlreadyFinished, + NotFound, +} + +/// 单个任务的可轮询记录。 +#[derive(Debug, Clone, Serialize)] +pub(super) struct TaskRecord { + pub(super) id: String, + pub(super) kind: &'static str, + /// `queued` | `running` | `succeeded` | `failed` | `cancelled`。 + pub(super) status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) stage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) message: Option, + pub(super) created_at: u64, + pub(super) updated_at: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) started_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) finished_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) result: Option, + /// 取消标志,worker 的 should_cancel 检查它;不参与序列化。 + #[serde(skip)] + pub(super) cancel: Arc, + /// 进度日志(有界),经 task.logs 返回;不参与 task.status 序列化。 + #[serde(skip)] + pub(super) log: Vec, +} + +impl TaskRecord { + pub(super) fn is_finished(&self) -> bool { + matches!(self.status, "succeeded" | "failed" | "cancelled") + } +} + +struct TaskStore { + tasks: HashMap, + order: Vec, + seq: u64, + /// 任务历史持久化文件路径;`None` 表示纯内存(测试等非 daemon 场景)。 + persist_path: Option, +} + +/// daemon 任务历史持久化文件名(位于 state dir 内,`0600` 原子写)。 +pub(super) const TASKS_FILE_NAME: &str = "bat-tasks.json"; +/// 任务历史文件结构版本。 +pub(super) const TASKS_FILE_VERSION: u32 = 1; + +/// 任务历史文件的持久化形态(版本化;daemon 重启后恢复任务历史用)。 +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct PersistedTaskFile { + pub(super) version: u32, + /// 任务 ID 序号计数器;恢复它避免 pid 复用时新任务与历史任务撞 ID。 + pub(super) seq: u64, + pub(super) tasks: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct PersistedTaskRecord { + id: String, + kind: String, + pub(super) status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + stage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + message: Option, + created_at: u64, + updated_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + started_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + finished_at: Option, + /// `ApiError` 的序列化形态(code/kind/domain/location/message/retryable)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + log: Vec, +} + +/// 把持久化的任务类型映射回静态字符串;未识别(如未来版本新增)返回 `None`。 +fn task_kind_static(kind: &str) -> Option<&'static str> { + match kind { + RPC_METHOD_RESOURCE_SYNC => Some(RPC_METHOD_RESOURCE_SYNC), + RPC_METHOD_RESOURCE_VERIFY => Some(RPC_METHOD_RESOURCE_VERIFY), + RPC_METHOD_RESOURCE_REPAIR => Some(RPC_METHOD_RESOURCE_REPAIR), + RPC_METHOD_CATALOG_REFRESH => Some(RPC_METHOD_CATALOG_REFRESH), + _ => None, + } +} + +/// 把持久化的任务状态映射回静态字符串;未识别返回 `None`。 +fn task_status_static(status: &str) -> Option<&'static str> { + match status { + "queued" => Some("queued"), + "running" => Some("running"), + "succeeded" => Some("succeeded"), + "failed" => Some("failed"), + "cancelled" => Some("cancelled"), + _ => None, + } +} + +impl PersistedTaskRecord { + fn from_record(record: &TaskRecord) -> Self { + Self { + id: record.id.clone(), + kind: record.kind.to_string(), + status: record.status.to_string(), + stage: record.stage.clone(), + message: record.message.clone(), + created_at: record.created_at, + updated_at: record.updated_at, + started_at: record.started_at, + finished_at: record.finished_at, + error: record + .error + .as_ref() + .and_then(|error| serde_json::to_value(error).ok()), + result: record.result.clone(), + log: record.log.clone(), + } + } + + /// 还原为内存任务记录;kind/status 未识别时返回 `None`(调用方计数跳过)。 + fn into_record(self) -> Option { + let kind = task_kind_static(&self.kind)?; + let status = task_status_static(&self.status)?; + // 错误从序列化形态还原:code 经码表反查(未登记回退 internal), + // location 固定为任务执行器(当前全部任务错误的唯一来源)。 + let error = self.error.as_ref().map(|value| { + let code = value + .get("code") + .and_then(serde_json::Value::as_str) + .and_then(ErrorCode::from_id) + .unwrap_or(ErrorCode::INTERNAL); + let message = value + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("<持久化错误信息缺失>") + .to_string(); + ApiError::new(code, "task.executor", message) + }); + Some(TaskRecord { + id: self.id, + kind, + status, + stage: self.stage, + message: self.message, + created_at: self.created_at, + updated_at: self.updated_at, + started_at: self.started_at, + finished_at: self.finished_at, + error, + result: self.result, + cancel: Arc::new(AtomicBool::new(false)), + log: self.log, + }) + } +} + +/// 读取任务历史文件。文件缺失返回 `Ok(None)`;symlink、解析失败或版本不支持返回 `Err`。 +fn load_persisted_tasks(path: &Path) -> Result, String> { + let Some(bytes) = read_file_no_symlink(path, "任务历史")? else { + return Ok(None); + }; + let file: PersistedTaskFile = serde_json::from_slice(&bytes) + .map_err(|error| format!("解析任务历史失败 {}:{error}", path.display()))?; + if file.version != TASKS_FILE_VERSION { + return Err(format!( + "不支持的任务历史版本 {},文件 {}", + file.version, + path.display() + )); + } + Ok(Some(file)) +} + +/// 任务注册表句柄:包住内存存储,供 RPC handler 与 worker 共享。 +/// +/// 通过方法访问(而非直接摸内部 map),便于将来换成 Redis 等持久化后端。 +#[derive(Clone)] +pub(super) struct TaskRegistry { + inner: Arc>, +} + +impl TaskRegistry { + /// 纯内存注册表(无持久化);生产 daemon 走 [`Self::with_persistence`]。 + #[cfg(test)] + pub(super) fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(TaskStore { + tasks: HashMap::new(), + order: Vec::new(), + seq: 0, + persist_path: None, + })), + } + } + + /// 从 state dir 恢复任务历史并启用持久化。 + /// + /// 中断时仍处于 queued/running 的任务标记为 `failed`(`TASK_INTERRUPTED`); + /// 文件缺失按空历史处理;文件损坏或版本不支持时改名 `.corrupt` 留证并从 + /// 空历史开始。返回注册表与恢复摘要(供 daemon 日志记录)。 + pub(super) fn with_persistence(state_dir: &Path) -> (Self, String) { + let path = state_dir.join(TASKS_FILE_NAME); + let now = unix_seconds_now(); + let mut seq = 0; + let mut tasks = HashMap::new(); + let mut order = Vec::new(); + let summary = match load_persisted_tasks(&path) { + Ok(None) => "无历史任务文件,从空任务历史开始".to_string(), + Ok(Some(file)) => { + seq = file.seq; + let total = file.tasks.len(); + let mut interrupted = 0usize; + let mut skipped = 0usize; + for persisted in file.tasks { + let Some(mut record) = persisted.into_record() else { + skipped += 1; + continue; + }; + if !record.is_finished() { + interrupted += 1; + record.status = "failed"; + record.finished_at = Some(now); + record.updated_at = now; + record.error = Some(ApiError::new( + ErrorCode::TASK_INTERRUPTED, + "task.executor", + "daemon 停止/重启导致任务中断", + )); + record + .log + .push("[daemon] 任务因 daemon 停止/重启而中断".to_string()); + } + if tasks.insert(record.id.clone(), record.clone()).is_none() { + order.push(record.id); + } else { + skipped += 1; + } + } + format!("恢复任务历史 {total} 条(标记中断 {interrupted} 条,跳过无法识别 {skipped} 条)") + } + Err(error) => { + // 保留损坏文件供诊断(改名而非覆盖),从空历史开始。 + let corrupt = path.with_extension("json.corrupt"); + if fs::rename(&path, &corrupt).is_ok() { + format!( + "任务历史不可用({error});原文件已改名保留为 {}", + corrupt.display() + ) + } else { + format!("任务历史不可用({error});且无法改名保留原文件") + } + } + }; + let registry = Self { + inner: Arc::new(Mutex::new(TaskStore { + tasks, + order, + seq, + persist_path: Some(path), + })), + }; + // 把中断标记(或空历史)立即写回,保证文件与内存视图一致。 + registry.lock().persist(); + (registry, summary) + } + + fn lock(&self) -> std::sync::MutexGuard<'_, TaskStore> { + self.inner + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + } + + /// 创建 queued 任务并返回 task_id。 + pub(super) fn create(&self, kind: TaskKind) -> String { + let now = unix_seconds_now(); + let mut store = self.lock(); + store.seq += 1; + let id = format!("task-{}-{}", std::process::id(), store.seq); + let record = TaskRecord { + id: id.clone(), + kind: kind.method(), + status: "queued", + stage: None, + message: None, + created_at: now, + updated_at: now, + started_at: None, + finished_at: None, + error: None, + result: None, + cancel: Arc::new(AtomicBool::new(false)), + log: Vec::new(), + }; + store.tasks.insert(id.clone(), record); + store.order.push(id.clone()); + store.prune(); + store.persist(); + id + } + + pub(super) fn update(&self, id: &str, update: F) { + let mut store = self.lock(); + let mut status_changed = false; + if let Some(record) = store.tasks.get_mut(id) { + let previous_status = record.status; + update(record); + record.updated_at = unix_seconds_now(); + status_changed = record.status != previous_status; + } + // 只在生命周期转换时落盘;stage/message/log 的高频进度更新以内存为准, + // 随下一次转换一起写入(避免每个进度事件一次磁盘写)。 + if status_changed { + store.persist(); + } + } + + pub(super) fn get(&self, id: &str) -> Option { + self.lock().tasks.get(id).cloned() + } + + /// 返回任务的取消标志(与 worker 共享同一 Arc)。 + pub(super) fn cancel_flag(&self, id: &str) -> Option> { + self.lock() + .tasks + .get(id) + .map(|record| Arc::clone(&record.cancel)) + } + + /// 追加一行进度日志,超出上限时丢弃最旧的。 + pub(super) fn append_log(&self, id: &str, line: String) { + let mut store = self.lock(); + if let Some(record) = store.tasks.get_mut(id) { + record.log.push(line); + if record.log.len() > MAX_TASK_LOG_LINES { + let overflow = record.log.len() - MAX_TASK_LOG_LINES; + record.log.drain(0..overflow); + } + } + } + + /// 返回任务的进度日志。 + pub(super) fn logs(&self, id: &str) -> Option> { + self.lock().tasks.get(id).map(|record| record.log.clone()) + } + + /// 请求取消任务:未结束的置取消标志,已结束的原样返回,不存在返回 NotFound。 + pub(super) fn request_cancel(&self, id: &str) -> CancelOutcome { + let store = self.lock(); + match store.tasks.get(id) { + None => CancelOutcome::NotFound, + Some(record) if record.is_finished() => CancelOutcome::AlreadyFinished, + Some(record) => { + record.cancel.store(true, Ordering::Relaxed); + CancelOutcome::Requested + } + } + } + + /// 返回全部任务,最新创建的在前。 + pub(super) fn list(&self) -> Vec { + let store = self.lock(); + store + .order + .iter() + .rev() + .filter_map(|id| store.tasks.get(id).cloned()) + .collect() + } +} + +impl TaskStore { + /// 把当前任务历史落盘(`0600` 原子写、不跟随 symlink)。 + /// + /// 持久化未启用时为 no-op;写失败只记 stderr(进 daemon 日志), + /// 不让持久化故障拖垮任务执行本身。 + fn persist(&self) { + let Some(path) = &self.persist_path else { + return; + }; + let file = PersistedTaskFile { + version: TASKS_FILE_VERSION, + seq: self.seq, + tasks: self + .order + .iter() + .filter_map(|id| self.tasks.get(id)) + .map(PersistedTaskRecord::from_record) + .collect(), + }; + match serde_json::to_vec_pretty(&file) { + Ok(bytes) => { + if let Err(error) = write_file_atomic(path, &bytes, PRIVATE_FILE_MODE, "任务历史") + { + eprintln!("[daemon] 任务历史落盘失败:{error}"); + } + } + Err(error) => eprintln!("[daemon] 任务历史序列化失败:{error}"), + } + } + + /// 裁剪最旧的已结束任务,把内存占用控制在上限内;运行中/排队中的任务不裁剪。 + fn prune(&mut self) { + while self.order.len() > MAX_RETAINED_TASKS { + let Some(position) = self.order.iter().position(|id| { + self.tasks + .get(id) + .map(TaskRecord::is_finished) + .unwrap_or(true) + }) else { + break; + }; + let id = self.order.remove(position); + self.tasks.remove(&id); + } + } +} + +/// 提交给任务 worker 的作业(配置已按任务类型派生完毕)。 +pub(super) struct TaskJob { + pub(super) id: String, + pub(super) config: OfficialUpdateConfig, + /// 与任务记录共享的取消标志。 + pub(super) cancel: Arc, +} + +/// daemon 任务上下文:RPC handler 借它创建任务、入队和读取。 +#[derive(Clone)] +pub(super) struct DaemonTaskContext { + pub(super) registry: TaskRegistry, + pub(super) queue: mpsc::Sender, + pub(super) base_config: OfficialUpdateConfig, + pub(super) restart_controller: DaemonRestartController, +} + +pub(super) type DaemonRestartController = fn(&Path) -> anyhow::Result; + +/// 任务 worker:单线程 FIFO 消费任务队列,串行执行官方同步/校验。 +/// +/// 每个任务执行前获取进程内 `sync_lock`,与 watch 循环互斥(等待而非撞文件锁失败); +/// 进度写入任务记录;`should_cancel` 接 daemon 停止标志,停机时中止在途任务。 +pub(super) fn run_task_worker( + receiver: mpsc::Receiver, + registry: TaskRegistry, + sync_lock: Arc>, + control: DaemonControl, +) { + let service = OfficialUpdateService::new(); + for job in receiver { + registry.update(&job.id, |record| { + record.status = "running"; + record.started_at = Some(unix_seconds_now()); + }); + + let cancel = Arc::clone(&job.cancel); + let run_result = { + let _sync_guard = sync_lock + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let progress_registry = registry.clone(); + let progress_id = job.id.clone(); + let cancel_check = Arc::clone(&cancel); + let stop_control = Arc::clone(&control); + service.run_with_progress_and_cancellation( + &job.config, + |event| { + progress_registry + .append_log(&progress_id, format!("[{}] {}", event.stage, event.message)); + progress_registry.update(&progress_id, |record| { + record.stage = Some(event.stage.to_string()); + record.message = Some(event.message.clone()); + }); + }, + || { + cancel_check.load(Ordering::Relaxed) + || daemon_control_stop_requested(Some(&stop_control)) + }, + ) + }; + + match run_result { + Ok(report) => registry.update(&job.id, |record| { + record.status = "succeeded"; + record.finished_at = Some(unix_seconds_now()); + record.result = serde_json::to_value(&report).ok(); + }), + Err(error) => { + let cancelled = cancel.load(Ordering::Relaxed); + // 下载失败携带类型化 DownloadError(含准确网络域码);其余归 internal。 + let code = error + .downcast_ref::() + .map(bat_infrastructure::DownloadError::code) + .unwrap_or(ErrorCode::INTERNAL); + registry.update(&job.id, |record| { + record.finished_at = Some(unix_seconds_now()); + if cancelled { + record.status = "cancelled"; + record.error = Some(ApiError::new( + ErrorCode::INTERNAL, + "task.executor", + "任务已取消", + )); + } else { + record.status = "failed"; + record.error = + Some(ApiError::new(code, "task.executor", error.to_string())); + } + }); + } + } + } +} diff --git a/infrastructure/src/bin/bat/translation_query.rs b/infrastructure/src/bin/bat/translation_query.rs new file mode 100644 index 0000000..fa296f3 --- /dev/null +++ b/infrastructure/src/bin/bat/translation_query.rs @@ -0,0 +1,232 @@ +use super::*; + +pub(super) fn build_translation_tasks_report( + state_dir: &Path, + query: OfficialTextUnitTaskQuery, + offset: usize, + limit: usize, +) -> anyhow::Result { + let (_, version_state) = read_daemon_resource_state(state_dir)?; + let current = version_state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()); + let Some(record) = current else { + return Ok(serde_json::json!({ "available": false })); + }; + let task_queue_path = record + .resource_root + .join(bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE); + let task_repository_path = + SqliteTranslationTaskRepository::repository_path(&record.resource_root); + let Some(queue) = bat_infrastructure::read_textunit_task_queue_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_task_queue_path": task_queue_path, + "task_repository_path": task_repository_path, + "task_repository_available": false, + })); + }; + let task_repository_available = + sqlite_file_exists_no_symlink(&task_repository_path, "翻译任务状态数据库")?; + let (total_entries, entries) = if task_repository_available { + query_translation_task_repository(&task_repository_path, &query, offset, limit)? + } else { + let mut queue_query = query.clone(); + queue_query.task_status = None; + queue_query.has_failure_reason = None; + let matches = bat_infrastructure::query_textunit_tasks(&queue, &queue_query); + let persisted = matches + .into_iter() + .cloned() + .map(|task| { + bat_infrastructure::PersistedTranslationTask::from_queued_task( + task, + queue.generated_unix_seconds, + ) + }) + .filter(|task| { + query + .task_status + .as_ref() + .is_none_or(|status| task.task_status.as_str() == status) + }) + .filter(|task| { + query + .has_failure_reason + .is_none_or(|has_reason| task.failure_reason.is_some() == has_reason) + }) + .collect::>(); + let total_entries = persisted.len(); + let entries = persisted + .into_iter() + .skip(offset) + .take(limit) + .collect::>(); + (total_entries as u64, entries) + }; + Ok(serde_json::json!({ + "available": true, + "current_version_id": record.id, + "resource_root": record.resource_root, + "textunit_task_queue_path": task_queue_path, + "task_repository_path": task_repository_path, + "task_repository_available": task_repository_available, + "task_repository_schema_version": bat_infrastructure::TRANSLATION_TASK_SCHEMA_VERSION, + "summary": queue.summary, + "total_entries": total_entries, + "offset": offset, + "limit": limit, + "query": translation_task_query_json(&query), + "entries": entries, + })) +} + +pub(super) fn build_translation_handoff_report( + state_dir: &Path, +) -> anyhow::Result { + let (_, version_state) = read_daemon_resource_state(state_dir)?; + let current = version_state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()); + let Some(record) = current else { + return Ok(serde_json::json!({ "available": false })); + }; + let resource_root = &record.resource_root; + let task_queue_path = resource_root.join(bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE); + let handoff_path = resource_root.join(bat_infrastructure::TRANSLATION_HANDOFF_FILE); + let repository_path = SqliteTranslationTaskRepository::repository_path(resource_root); + let Some(queue) = bat_infrastructure::read_textunit_task_queue_at(resource_root) + .map_err(anyhow::Error::msg)? + else { + return Ok(serde_json::json!({ + "available": false, + "current_version_id": record.id, + "resource_root": resource_root, + "textunit_task_queue_path": task_queue_path, + "translation_handoff_path": handoff_path, + "task_repository_path": repository_path, + })); + }; + let task_repository_available = + sqlite_file_exists_no_symlink(&repository_path, "翻译任务状态数据库")?; + let tasks = if task_repository_available { + 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}"))?; + repository + .list(&OfficialTextUnitTaskQuery::default()) + .await + .map_err(|error| anyhow::anyhow!("{error}")) + })? + } else { + queue + .tasks + .iter() + .cloned() + .map(|task| { + bat_infrastructure::PersistedTranslationTask::from_queued_task( + task, + queue.generated_unix_seconds, + ) + }) + .collect::>() + }; + let handoff = bat_infrastructure::build_translation_handoff(&queue, &tasks); + let handoff_file_available = sqlite_file_exists_no_symlink(&handoff_path, "翻译 handoff")?; + Ok(serde_json::json!({ + "available": true, + "current_version_id": record.id, + "resource_root": resource_root, + "textunit_task_queue_path": task_queue_path, + "translation_handoff_path": handoff_path, + "translation_handoff_file_available": handoff_file_available, + "task_repository_path": repository_path, + "task_repository_available": task_repository_available, + "task_repository_schema_version": bat_infrastructure::TRANSLATION_TASK_SCHEMA_VERSION, + "handoff_schema_version": bat_infrastructure::TRANSLATION_HANDOFF_SCHEMA_VERSION, + "handoff": handoff, + })) +} + +pub(super) fn update_translation_task_status_report( + state_dir: &Path, + params: Option<&serde_json::Value>, +) -> anyhow::Result { + let task_id = rpc_string_param(params, "task_id") + .ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 task_id"))?; + let status_label = rpc_string_param(params, "status") + .ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 status"))?; + let status = TranslationTaskStatus::parse(status_label) + .ok_or_else(|| anyhow::anyhow!("不支持的翻译任务 worker 状态:{status_label}"))?; + let failure_reason = rpc_string_param(params, "failure_reason") + .or_else(|| rpc_string_param(params, "reason")) + .map(str::to_string); + let provider_run_id = rpc_string_param(params, "provider_run_id").map(str::to_string); + let (_, version_state) = read_daemon_resource_state(state_dir)?; + let current = version_state + .as_ref() + .and_then(|state| state.current_completed_version.as_ref()) + .ok_or_else(|| anyhow::anyhow!("没有可更新翻译任务的当前官方 release"))?; + let repository_path = SqliteTranslationTaskRepository::repository_path(¤t.resource_root); + if !sqlite_file_exists_no_symlink(&repository_path, "翻译任务状态数据库")? { + return Err(anyhow::anyhow!( + "翻译任务状态数据库不存在:{}", + repository_path.display() + )); + } + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let task = runtime.block_on(async { + let repository = SqliteTranslationTaskRepository::open(&repository_path) + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + repository + .update_status(task_id, status, failure_reason, provider_run_id) + .await + .map_err(|error| anyhow::anyhow!("{error}")) + })?; + Ok(serde_json::json!({ + "available": true, + "current_version_id": current.id, + "task_repository_path": repository_path, + "entry": task, + })) +} + +pub(super) fn textunit_query_json(query: &OfficialTextUnitQuery) -> serde_json::Value { + serde_json::json!({ + "destination": query.destination.clone(), + "path_pattern": query.path_pattern.clone(), + "archive_entry": query.archive_entry.clone(), + "path_id": query.path_id, + "class_id": query.class_id, + "field_path": query.field_path.clone(), + "format": query.format.clone(), + }) +} + +pub(super) fn translation_task_query_json(query: &OfficialTextUnitTaskQuery) -> serde_json::Value { + serde_json::json!({ + "task_id": query.task_id.clone(), + "official_release_id": query.official_release_id.clone(), + "destination": query.destination.clone(), + "path_pattern": query.path_pattern.clone(), + "archive_entry": query.archive_entry.clone(), + "status": query.status.clone(), + "task_status": query.task_status.clone(), + "parse_status": query.parse_status.clone(), + "text_unit_format": query.text_unit_format.clone(), + "has_reason": query.has_reason, + "has_failure_reason": query.has_failure_reason, + }) +} diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index 01ee442..1376a4c 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -1,12043 +1,6 @@ -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}; - -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//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 -===================================================================================== -"#; +#[path = "bat/app.rs"] +mod app; fn main() { - match run() { - Ok(exit_code) if exit_code != 0 => std::process::exit(exit_code), - Ok(_) => {} - Err(error) => { - let error_message = error.to_string(); - let exit_code = if is_locked_error(&error_message) { - EXIT_LOCKED - } else { - EXIT_ERROR - }; - let payload = ErrorReport { - status: "error", - exit_code, - error: error_message, - next_retry_seconds: None, - }; - eprintln!( - "{}", - serde_json::to_string_pretty(&payload) - .unwrap_or_else(|_| "{\"status\":\"error\"}".to_string()) - ); - std::process::exit(exit_code); - } - } -} - -fn run() -> anyhow::Result { - bootstrap_env_file(); - let options = parse_args()?; - if matches!( - options.command, - CliCommand::Run | CliCommand::Refresh | CliCommand::Verify | CliCommand::Repair - ) && !options.daemon - && options.banner - { - print_startup_banner(); - } - match options.command { - CliCommand::Run => { - if options.daemon { - let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; - run_daemon_start(options)?; - } else if options.watch { - if !options.daemon_child { - assert_no_live_daemon_output_conflict(&options, "watch")?; - } - run_watch(options)?; - } else { - assert_no_live_daemon_output_conflict(&options, "run")?; - let mut logger = ProgressLogger::new(options.progress); - let report = - OfficialUpdateService::new().run_with_progress(&options.config, |event| { - logger.log(event); - })?; - if should_print_status(report.update_status, options.quiet_up_to_date) { - print_report(options.output_format, &report)?; - } - } - Ok(0) - } - CliCommand::Status => { - let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; - print_daemon_status(&options.state_dir, options.output_format)?; - Ok(0) - } - CliCommand::Stop => { - let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; - stop_daemon(&options.state_dir, options.output_format)?; - Ok(0) - } - CliCommand::Restart => { - let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; - run_daemon_restart(&options, "restart")?; - Ok(0) - } - CliCommand::Reload => { - let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; - run_daemon_restart(&options, "reload")?; - Ok(0) - } - CliCommand::Refresh => { - run_sync_command(&options, "refresh")?; - Ok(0) - } - CliCommand::Verify => { - let healthy = run_verify_command(&options)?; - Ok(if healthy { 0 } else { EXIT_ERROR }) - } - CliCommand::Repair => { - run_sync_command(&options, "repair")?; - Ok(0) - } - CliCommand::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, -} - -#[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, - query_resource_type: Option, - query_hash: Option, - query_path_pattern: Option, - query_official_release_id: Option, - query_platform: Option, - query_destination: Option, - query_bundle_path: Option, - query_archive_entry: Option, - query_task_status: Option, - query_worker_status: Option, - query_parse_status: Option, - query_path_id: Option, - query_class_id: Option, - query_field_path: Option, - query_format: Option, - query_has_reason: Option, - query_has_failure_reason: Option, - query_option_explicit: bool, - patch_kind: Option, - patch_source_path: Option, - patch_patch_path: Option, - patch_target_path: Option, - unityfs_bundle_path: Option, - unityfs_serialized_file_path: Option, - unityfs_path_id: Option, - unityfs_field_path: Option, - unityfs_replacement_path: Option, - unityfs_replacement_text: Option, - unityfs_expected_name: Option, - unityfs_expected_value: Option, - unityfs_replacement_value: Option, - unityfs_expected_semantic_value: Option, - 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::(); - 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, - state_dir: PathBuf, - log_path: PathBuf, - #[serde(default)] - structured_log_path: Option, - started_unix_seconds: u64, - updated_unix_seconds: u64, - #[serde(default)] - last_success_unix_seconds: Option, - #[serde(default)] - next_check_unix_seconds: Option, - last_update_status: Option, - last_error: Option, - next_retry_seconds: Option, - #[serde(default)] - current_stage: Option, - #[serde(default)] - status_code: Option, - #[serde(default)] - current_message: Option, - #[serde(default)] - download_progress: Option, - pending_scheduled_force: bool, - next_forced_refresh_unix_seconds: Option, - command: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct DaemonDownloadProgress { - index: usize, - total: usize, - url: String, - status: Option, - bytes: Option, - transferred_bytes: Option, - #[serde(default)] - failure_kind: Option, - #[serde(default)] - failure_http_status: Option, - #[serde(default)] - failure_retryable: Option, - #[serde(default)] - failure_attempts: Option, - #[serde(default)] - quarantined: Option, - #[serde(default)] - verification: Option, - #[serde(default)] - official_hash: Option, -} - -#[derive(Debug)] -struct DaemonStatusUpdate<'a> { - state: &'a str, - last_update_status: Option, - last_error: Option, - next_retry_seconds: Option, - last_success_unix_seconds: Option, - next_check_unix_seconds: Option, - pending_scheduled_force: bool, - next_forced_refresh_at: SystemTime, -} - -type DaemonControl = Arc<(Mutex, Condvar)>; - -#[derive(Debug, Default)] -struct DaemonControlState { - stop_requested: bool, - refresh_requested: bool, - force_refresh_requested: bool, - reload_requested: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DaemonWake { - Timeout, - Stop, - Refresh { force: bool }, - Reload, -} - -#[derive(Debug, Deserialize)] -struct JsonRpcRequest { - #[allow(dead_code)] - jsonrpc: Option, - id: Option, - method: String, - params: Option, -} - -#[derive(Debug, Serialize)] -struct JsonRpcResponse { - jsonrpc: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct JsonRpcError { - code: i32, - message: String, -} - -#[derive(Debug, Deserialize)] -struct JsonRpcClientResponse { - #[allow(dead_code)] - jsonrpc: String, - #[allow(dead_code)] - id: Option, - result: Option, - error: Option, -} - -#[derive(Debug, Serialize)] -struct DaemonRpcAck { - command: &'static str, - status: &'static str, - message: &'static str, - state_dir: PathBuf, - socket_path: PathBuf, - #[serde(skip_serializing_if = "Option::is_none")] - controller_pid: Option, - #[serde(skip_serializing_if = "Option::is_none")] - force: Option, -} - -// 规范方法名采用国际惯例的 `.`。`bat.*` 保留为向后兼容别名。 -const RPC_METHOD_STATUS: &str = "daemon.status"; -const RPC_METHOD_STOP: &str = "daemon.stop"; -const RPC_METHOD_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"; - -/// 保留的已完成任务上限(内存态,超出后裁剪最旧的已结束任务)。 -const MAX_RETAINED_TASKS: usize = 64; -/// 每个任务保留的进度日志行数上限。 -const MAX_TASK_LOG_LINES: usize = 200; - -/// 任务类型:目前覆盖官方同步、校验与 catalog 更新检查。 -#[derive(Debug, Clone, Copy)] -enum TaskKind { - Sync, - Verify, - Repair, - /// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。 - Refresh, -} - -impl TaskKind { - fn method(self) -> &'static str { - match self { - Self::Sync => RPC_METHOD_RESOURCE_SYNC, - Self::Verify => RPC_METHOD_RESOURCE_VERIFY, - Self::Repair => RPC_METHOD_RESOURCE_REPAIR, - Self::Refresh => RPC_METHOD_CATALOG_REFRESH, - } - } - - /// 由 daemon 基准配置派生该任务的实际同步配置。 - fn build_config(self, base: &OfficialUpdateConfig, force: bool) -> OfficialUpdateConfig { - let mut config = base.clone(); - match self { - Self::Sync => { - config.dry_run = false; - config.force = config.force || force; - } - Self::Verify => { - config.dry_run = true; - config.plan = true; - config.audit_local = true; - config.repair = false; - config.force = false; - } - Self::Repair => { - config.dry_run = false; - config.audit_local = true; - config.repair = true; - config.force = false; - } - Self::Refresh => { - config.dry_run = true; - config.plan = true; - config.audit_local = false; - config.repair = false; - config.force = force; - } - } - config - } -} - -/// 请求取消任务的结果。 -enum CancelOutcome { - Requested, - AlreadyFinished, - NotFound, -} - -/// 单个任务的可轮询记录。 -#[derive(Debug, Clone, Serialize)] -struct TaskRecord { - id: String, - kind: &'static str, - /// `queued` | `running` | `succeeded` | `failed` | `cancelled`。 - status: &'static str, - #[serde(skip_serializing_if = "Option::is_none")] - stage: Option, - #[serde(skip_serializing_if = "Option::is_none")] - message: Option, - created_at: u64, - updated_at: u64, - #[serde(skip_serializing_if = "Option::is_none")] - started_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - finished_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - /// 取消标志,worker 的 should_cancel 检查它;不参与序列化。 - #[serde(skip)] - cancel: Arc, - /// 进度日志(有界),经 task.logs 返回;不参与 task.status 序列化。 - #[serde(skip)] - log: Vec, -} - -impl TaskRecord { - fn is_finished(&self) -> bool { - matches!(self.status, "succeeded" | "failed" | "cancelled") - } -} - -struct TaskStore { - tasks: HashMap, - order: Vec, - seq: u64, - /// 任务历史持久化文件路径;`None` 表示纯内存(测试等非 daemon 场景)。 - persist_path: Option, -} - -/// daemon 任务历史持久化文件名(位于 state dir 内,`0600` 原子写)。 -const TASKS_FILE_NAME: &str = "bat-tasks.json"; -/// 任务历史文件结构版本。 -const TASKS_FILE_VERSION: u32 = 1; - -/// 任务历史文件的持久化形态(版本化;daemon 重启后恢复任务历史用)。 -#[derive(Debug, Serialize, Deserialize)] -struct PersistedTaskFile { - version: u32, - /// 任务 ID 序号计数器;恢复它避免 pid 复用时新任务与历史任务撞 ID。 - seq: u64, - tasks: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -struct PersistedTaskRecord { - id: String, - kind: String, - status: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - stage: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - message: Option, - created_at: u64, - updated_at: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - started_at: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - finished_at: Option, - /// `ApiError` 的序列化形态(code/kind/domain/location/message/retryable)。 - #[serde(default, skip_serializing_if = "Option::is_none")] - error: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - log: Vec, -} - -/// 把持久化的任务类型映射回静态字符串;未识别(如未来版本新增)返回 `None`。 -fn task_kind_static(kind: &str) -> Option<&'static str> { - match kind { - RPC_METHOD_RESOURCE_SYNC => Some(RPC_METHOD_RESOURCE_SYNC), - RPC_METHOD_RESOURCE_VERIFY => Some(RPC_METHOD_RESOURCE_VERIFY), - RPC_METHOD_RESOURCE_REPAIR => Some(RPC_METHOD_RESOURCE_REPAIR), - RPC_METHOD_CATALOG_REFRESH => Some(RPC_METHOD_CATALOG_REFRESH), - _ => None, - } -} - -/// 把持久化的任务状态映射回静态字符串;未识别返回 `None`。 -fn task_status_static(status: &str) -> Option<&'static str> { - match status { - "queued" => Some("queued"), - "running" => Some("running"), - "succeeded" => Some("succeeded"), - "failed" => Some("failed"), - "cancelled" => Some("cancelled"), - _ => None, - } -} - -impl PersistedTaskRecord { - fn from_record(record: &TaskRecord) -> Self { - Self { - id: record.id.clone(), - kind: record.kind.to_string(), - status: record.status.to_string(), - stage: record.stage.clone(), - message: record.message.clone(), - created_at: record.created_at, - updated_at: record.updated_at, - started_at: record.started_at, - finished_at: record.finished_at, - error: record - .error - .as_ref() - .and_then(|error| serde_json::to_value(error).ok()), - result: record.result.clone(), - log: record.log.clone(), - } - } - - /// 还原为内存任务记录;kind/status 未识别时返回 `None`(调用方计数跳过)。 - fn into_record(self) -> Option { - let kind = task_kind_static(&self.kind)?; - let status = task_status_static(&self.status)?; - // 错误从序列化形态还原:code 经码表反查(未登记回退 internal), - // location 固定为任务执行器(当前全部任务错误的唯一来源)。 - let error = self.error.as_ref().map(|value| { - let code = value - .get("code") - .and_then(serde_json::Value::as_str) - .and_then(ErrorCode::from_id) - .unwrap_or(ErrorCode::INTERNAL); - let message = value - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("<持久化错误信息缺失>") - .to_string(); - ApiError::new(code, "task.executor", message) - }); - Some(TaskRecord { - id: self.id, - kind, - status, - stage: self.stage, - message: self.message, - created_at: self.created_at, - updated_at: self.updated_at, - started_at: self.started_at, - finished_at: self.finished_at, - error, - result: self.result, - cancel: Arc::new(AtomicBool::new(false)), - log: self.log, - }) - } -} - -/// 读取任务历史文件。文件缺失返回 `Ok(None)`;symlink、解析失败或版本不支持返回 `Err`。 -fn load_persisted_tasks(path: &Path) -> Result, String> { - let Some(bytes) = read_file_no_symlink(path, "任务历史")? else { - return Ok(None); - }; - let file: PersistedTaskFile = serde_json::from_slice(&bytes) - .map_err(|error| format!("解析任务历史失败 {}:{error}", path.display()))?; - if file.version != TASKS_FILE_VERSION { - return Err(format!( - "不支持的任务历史版本 {},文件 {}", - file.version, - path.display() - )); - } - Ok(Some(file)) -} - -/// 任务注册表句柄:包住内存存储,供 RPC handler 与 worker 共享。 -/// -/// 通过方法访问(而非直接摸内部 map),便于将来换成 Redis 等持久化后端。 -#[derive(Clone)] -struct TaskRegistry { - inner: Arc>, -} - -impl TaskRegistry { - /// 纯内存注册表(无持久化);生产 daemon 走 [`Self::with_persistence`]。 - #[cfg(test)] - fn new() -> Self { - Self { - inner: Arc::new(Mutex::new(TaskStore { - tasks: HashMap::new(), - order: Vec::new(), - seq: 0, - persist_path: None, - })), - } - } - - /// 从 state dir 恢复任务历史并启用持久化。 - /// - /// 中断时仍处于 queued/running 的任务标记为 `failed`(`TASK_INTERRUPTED`); - /// 文件缺失按空历史处理;文件损坏或版本不支持时改名 `.corrupt` 留证并从 - /// 空历史开始。返回注册表与恢复摘要(供 daemon 日志记录)。 - fn with_persistence(state_dir: &Path) -> (Self, String) { - let path = state_dir.join(TASKS_FILE_NAME); - let now = unix_seconds_now(); - let mut seq = 0; - let mut tasks = HashMap::new(); - let mut order = Vec::new(); - let summary = match load_persisted_tasks(&path) { - Ok(None) => "无历史任务文件,从空任务历史开始".to_string(), - Ok(Some(file)) => { - seq = file.seq; - let total = file.tasks.len(); - let mut interrupted = 0usize; - let mut skipped = 0usize; - for persisted in file.tasks { - let Some(mut record) = persisted.into_record() else { - skipped += 1; - continue; - }; - if !record.is_finished() { - interrupted += 1; - record.status = "failed"; - record.finished_at = Some(now); - record.updated_at = now; - record.error = Some(ApiError::new( - ErrorCode::TASK_INTERRUPTED, - "task.executor", - "daemon 停止/重启导致任务中断", - )); - record - .log - .push("[daemon] 任务因 daemon 停止/重启而中断".to_string()); - } - if tasks.insert(record.id.clone(), record.clone()).is_none() { - order.push(record.id); - } else { - skipped += 1; - } - } - format!("恢复任务历史 {total} 条(标记中断 {interrupted} 条,跳过无法识别 {skipped} 条)") - } - Err(error) => { - // 保留损坏文件供诊断(改名而非覆盖),从空历史开始。 - let corrupt = path.with_extension("json.corrupt"); - if fs::rename(&path, &corrupt).is_ok() { - format!( - "任务历史不可用({error});原文件已改名保留为 {}", - corrupt.display() - ) - } else { - format!("任务历史不可用({error});且无法改名保留原文件") - } - } - }; - let registry = Self { - inner: Arc::new(Mutex::new(TaskStore { - tasks, - order, - seq, - persist_path: Some(path), - })), - }; - // 把中断标记(或空历史)立即写回,保证文件与内存视图一致。 - registry.lock().persist(); - (registry, summary) - } - - fn lock(&self) -> std::sync::MutexGuard<'_, TaskStore> { - self.inner - .lock() - .unwrap_or_else(|poison| poison.into_inner()) - } - - /// 创建 queued 任务并返回 task_id。 - fn create(&self, kind: TaskKind) -> String { - let now = unix_seconds_now(); - let mut store = self.lock(); - store.seq += 1; - let id = format!("task-{}-{}", std::process::id(), store.seq); - let record = TaskRecord { - id: id.clone(), - kind: kind.method(), - status: "queued", - stage: None, - message: None, - created_at: now, - updated_at: now, - started_at: None, - finished_at: None, - error: None, - result: None, - cancel: Arc::new(AtomicBool::new(false)), - log: Vec::new(), - }; - store.tasks.insert(id.clone(), record); - store.order.push(id.clone()); - store.prune(); - store.persist(); - id - } - - fn update(&self, id: &str, update: F) { - let mut store = self.lock(); - let mut status_changed = false; - if let Some(record) = store.tasks.get_mut(id) { - let previous_status = record.status; - update(record); - record.updated_at = unix_seconds_now(); - status_changed = record.status != previous_status; - } - // 只在生命周期转换时落盘;stage/message/log 的高频进度更新以内存为准, - // 随下一次转换一起写入(避免每个进度事件一次磁盘写)。 - if status_changed { - store.persist(); - } - } - - fn get(&self, id: &str) -> Option { - self.lock().tasks.get(id).cloned() - } - - /// 返回任务的取消标志(与 worker 共享同一 Arc)。 - fn cancel_flag(&self, id: &str) -> Option> { - self.lock() - .tasks - .get(id) - .map(|record| Arc::clone(&record.cancel)) - } - - /// 追加一行进度日志,超出上限时丢弃最旧的。 - fn append_log(&self, id: &str, line: String) { - let mut store = self.lock(); - if let Some(record) = store.tasks.get_mut(id) { - record.log.push(line); - if record.log.len() > MAX_TASK_LOG_LINES { - let overflow = record.log.len() - MAX_TASK_LOG_LINES; - record.log.drain(0..overflow); - } - } - } - - /// 返回任务的进度日志。 - fn logs(&self, id: &str) -> Option> { - self.lock().tasks.get(id).map(|record| record.log.clone()) - } - - /// 请求取消任务:未结束的置取消标志,已结束的原样返回,不存在返回 NotFound。 - fn request_cancel(&self, id: &str) -> CancelOutcome { - let store = self.lock(); - match store.tasks.get(id) { - None => CancelOutcome::NotFound, - Some(record) if record.is_finished() => CancelOutcome::AlreadyFinished, - Some(record) => { - record.cancel.store(true, Ordering::Relaxed); - CancelOutcome::Requested - } - } - } - - /// 返回全部任务,最新创建的在前。 - fn list(&self) -> Vec { - let store = self.lock(); - store - .order - .iter() - .rev() - .filter_map(|id| store.tasks.get(id).cloned()) - .collect() - } -} - -impl TaskStore { - /// 把当前任务历史落盘(`0600` 原子写、不跟随 symlink)。 - /// - /// 持久化未启用时为 no-op;写失败只记 stderr(进 daemon 日志), - /// 不让持久化故障拖垮任务执行本身。 - fn persist(&self) { - let Some(path) = &self.persist_path else { - return; - }; - let file = PersistedTaskFile { - version: TASKS_FILE_VERSION, - seq: self.seq, - tasks: self - .order - .iter() - .filter_map(|id| self.tasks.get(id)) - .map(PersistedTaskRecord::from_record) - .collect(), - }; - match serde_json::to_vec_pretty(&file) { - Ok(bytes) => { - if let Err(error) = write_file_atomic(path, &bytes, PRIVATE_FILE_MODE, "任务历史") - { - eprintln!("[daemon] 任务历史落盘失败:{error}"); - } - } - Err(error) => eprintln!("[daemon] 任务历史序列化失败:{error}"), - } - } - - /// 裁剪最旧的已结束任务,把内存占用控制在上限内;运行中/排队中的任务不裁剪。 - fn prune(&mut self) { - while self.order.len() > MAX_RETAINED_TASKS { - let Some(position) = self.order.iter().position(|id| { - self.tasks - .get(id) - .map(TaskRecord::is_finished) - .unwrap_or(true) - }) else { - break; - }; - let id = self.order.remove(position); - self.tasks.remove(&id); - } - } -} - -/// 提交给任务 worker 的作业(配置已按任务类型派生完毕)。 -struct TaskJob { - id: String, - config: OfficialUpdateConfig, - /// 与任务记录共享的取消标志。 - cancel: Arc, -} - -/// daemon 任务上下文:RPC handler 借它创建任务、入队和读取。 -#[derive(Clone)] -struct DaemonTaskContext { - registry: TaskRegistry, - queue: mpsc::Sender, - base_config: OfficialUpdateConfig, - restart_controller: DaemonRestartController, -} - -type DaemonRestartController = fn(&Path) -> anyhow::Result; - -/// 任务 worker:单线程 FIFO 消费任务队列,串行执行官方同步/校验。 -/// -/// 每个任务执行前获取进程内 `sync_lock`,与 watch 循环互斥(等待而非撞文件锁失败); -/// 进度写入任务记录;`should_cancel` 接 daemon 停止标志,停机时中止在途任务。 -fn run_task_worker( - receiver: mpsc::Receiver, - registry: TaskRegistry, - sync_lock: Arc>, - control: DaemonControl, -) { - let service = OfficialUpdateService::new(); - for job in receiver { - registry.update(&job.id, |record| { - record.status = "running"; - record.started_at = Some(unix_seconds_now()); - }); - - let cancel = Arc::clone(&job.cancel); - let run_result = { - let _sync_guard = sync_lock - .lock() - .unwrap_or_else(|poison| poison.into_inner()); - let progress_registry = registry.clone(); - let progress_id = job.id.clone(); - let cancel_check = Arc::clone(&cancel); - let stop_control = Arc::clone(&control); - service.run_with_progress_and_cancellation( - &job.config, - |event| { - progress_registry - .append_log(&progress_id, format!("[{}] {}", event.stage, event.message)); - progress_registry.update(&progress_id, |record| { - record.stage = Some(event.stage.to_string()); - record.message = Some(event.message.clone()); - }); - }, - || { - cancel_check.load(Ordering::Relaxed) - || daemon_control_stop_requested(Some(&stop_control)) - }, - ) - }; - - match run_result { - Ok(report) => registry.update(&job.id, |record| { - record.status = "succeeded"; - record.finished_at = Some(unix_seconds_now()); - record.result = serde_json::to_value(&report).ok(); - }), - Err(error) => { - let cancelled = cancel.load(Ordering::Relaxed); - // 下载失败携带类型化 DownloadError(含准确网络域码);其余归 internal。 - let code = error - .downcast_ref::() - .map(bat_infrastructure::DownloadError::code) - .unwrap_or(ErrorCode::INTERNAL); - registry.update(&job.id, |record| { - record.finished_at = Some(unix_seconds_now()); - if cancelled { - record.status = "cancelled"; - record.error = Some(ApiError::new( - ErrorCode::INTERNAL, - "task.executor", - "任务已取消", - )); - } else { - record.status = "failed"; - record.error = - Some(ApiError::new(code, "task.executor", error.to_string())); - } - }); - } - } - } -} - -/// 把 `bat.*` 兼容别名解析为规范的 `daemon.*` 方法名;其余原样返回。 -fn canonical_rpc_method(method: &str) -> &str { - match method { - "bat.status" => RPC_METHOD_STATUS, - "bat.stop" => RPC_METHOD_STOP, - "bat.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-stable:CLI 侧按进程生命周期处理; - // 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(skip_serializing_if = "Option::is_none")] - error: Option, - request_id: String, -} - -/// 生成进程内唯一的 request_id(`req--`)。 -fn next_request_id() -> String { - use std::sync::atomic::{AtomicU64, Ordering}; - static COUNTER: AtomicU64 = AtomicU64::new(1); - format!( - "req-{}-{}", - std::process::id(), - COUNTER.fetch_add(1, Ordering::Relaxed) - ) -} - -fn rpc_envelope_ok( - request_id: String, - status: &'static str, - data: serde_json::Value, -) -> RpcEnvelope { - RpcEnvelope { - ok: true, - status, - data: Some(data), - error: None, - request_id, - } -} - -fn rpc_envelope_error(request_id: String, error: ApiError) -> RpcEnvelope { - RpcEnvelope { - ok: false, - status: "error", - data: None, - error: Some(error), - request_id, - } -} - -/// 把内部 `anyhow::Result` 转成 envelope;成功为 ok,失败归为内部错误码。 -fn rpc_envelope_from_result( - request_id: String, - location: &'static str, - result: anyhow::Result, -) -> RpcEnvelope { - match result { - Ok(data) => rpc_envelope_ok(request_id, "ok", data), - Err(error) => rpc_envelope_error( - request_id, - ApiError::new(ErrorCode::INTERNAL, location, error.to_string()), - ), - } -} - -fn record_daemon_status( - enabled: bool, - state_dir: &Path, - logger: &mut ProgressLogger, - update: DaemonStatusUpdate<'_>, -) { - if !enabled { - return; - } - - if let Err(error) = update_daemon_status(state_dir, update) { - logger.log_text("daemon", format!("更新后台状态失败:{error}")); - } -} - -fn record_daemon_progress( - enabled: bool, - state_dir: &Path, - logger: &mut ProgressLogger, - event: &OfficialUpdateProgress, -) { - if !enabled { - return; - } - - if let Err(error) = update_daemon_progress(state_dir, event) { - logger.log_text("daemon", format!("更新后台进度失败:{error}")); - } -} - -#[derive(Debug)] -struct DaemonRpcServer { - socket_path: PathBuf, -} - -impl Drop for DaemonRpcServer { - fn drop(&mut self) { - let _ = fs::remove_file(&self.socket_path); - } -} - -#[derive(Debug)] -struct DaemonControlLock { - path: PathBuf, - pid: u32, -} - -impl DaemonControlLock { - fn acquire(state_dir: &Path) -> anyhow::Result { - validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?; - fs::create_dir_all(state_dir)?; - let path = daemon_control_lock_path(state_dir); - let pid = std::process::id(); - for attempt in 0..=1 { - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - options.mode(PRIVATE_FILE_MODE); - match options.open(&path) { - Ok(mut file) => { - file.write_all(pid.to_string().as_bytes())?; - return Ok(Self { path, pid }); - } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - if attempt == 0 && remove_recoverable_pid_lock(&path)? { - continue; - } - return Err(anyhow::anyhow!( - "后台控制命令已被锁定 (locked):{};{};如确认没有 bat 控制命令正在运行,可执行 clean-stable 清理", - path.display(), - describe_pid_lock_owner(&path)? - )); - } - Err(error) => { - return Err(anyhow::anyhow!( - "获取后台控制锁失败 {}:{error}", - path.display() - )); - } - } - } - - Err(anyhow::anyhow!("获取后台控制锁失败 {}", path.display())) - } -} - -impl Drop for DaemonControlLock { - fn drop(&mut self) { - let expected = self.pid.to_string(); - if fs::symlink_metadata(&self.path) - .map(|metadata| metadata.file_type().is_symlink()) - .unwrap_or(false) - { - return; - } - if fs::read_to_string(&self.path) - .map(|contents| contents.trim() == expected) - .unwrap_or(false) - { - let _ = fs::remove_file(&self.path); - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum PidLockState { - Missing, - Active(u32), - StalePid(u32), - Corrupt, -} - -fn classify_pid_lock_file(path: &Path) -> anyhow::Result { - let metadata = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(PidLockState::Missing); - } - Err(error) => return Err(error.into()), - }; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Ok(PidLockState::Corrupt); - } - let contents = fs::read_to_string(path)?; - let Some(pid) = parse_pid_value(&contents) else { - return Ok(PidLockState::Corrupt); - }; - if process_exists(pid) { - Ok(PidLockState::Active(pid)) - } else { - Ok(PidLockState::StalePid(pid)) - } -} - -fn remove_recoverable_pid_lock(path: &Path) -> anyhow::Result { - match classify_pid_lock_file(path)? { - PidLockState::Missing => Ok(false), - PidLockState::Active(_) => Ok(false), - PidLockState::StalePid(_) | PidLockState::Corrupt => { - fs::remove_file(path)?; - Ok(true) - } - } -} - -fn describe_pid_lock_owner(path: &Path) -> anyhow::Result { - Ok(match classify_pid_lock_file(path)? { - PidLockState::Missing => "锁文件已不存在".to_string(), - PidLockState::Active(pid) => format!("owner_pid={pid} 仍在运行"), - PidLockState::StalePid(pid) => format!("owner_pid={pid} 已失效"), - PidLockState::Corrupt => "锁文件内容不是有效 PID".to_string(), - }) -} - -fn parse_pid_value(contents: &str) -> Option { - contents.trim().parse::().ok().filter(|pid| *pid > 0) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct DaemonResourceConflict { - pid: Option, - daemon_root: Option, - target_root: PathBuf, -} - -fn assert_no_live_daemon_output_conflict( - options: &CliOptions, - command_name: &str, -) -> anyhow::Result<()> { - if options.daemon || options.daemon_child || options.config.dry_run { - return Ok(()); - } - - let Some(conflict) = - live_daemon_resource_conflict(&options.state_dir, &options.config.output_root)? - else { - return Ok(()); - }; - - let daemon_root = conflict - .daemon_root - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "未知".to_string()); - let pid = conflict - .pid - .map(|pid| pid.to_string()) - .unwrap_or_else(|| "未知".to_string()); - Err(anyhow::anyhow!( - "后台同步进程正在管理同一资源目录 (locked):command={command_name} pid={pid} daemon_output={} target_output={};请通过后台 refresh/reload 控制刷新,或先执行 stop 再手动写入资源", - daemon_root, - conflict.target_root.display() - )) -} - -fn live_daemon_resource_conflict( - state_dir: &Path, - output_root: &Path, -) -> anyhow::Result> { - let pid_path = daemon_pid_path(state_dir); - let status_path = daemon_status_path(state_dir); - let pid_from_file = read_pid_file(&pid_path)?; - let rpc_available = daemon_rpc_available(state_dir); - let pid_file_live = pid_from_file.map(process_exists).unwrap_or(false); - let status_file = match read_daemon_status_file(&status_path) { - Ok(status_file) => status_file, - Err(error) if pid_file_live || rpc_available => { - return Err(anyhow::anyhow!( - "后台同步进程可能仍在运行,但状态文件不可解析 (locked):{}:{error};请先执行 status/stop 或 clean-stable 恢复状态目录", - status_path.display() - )); - } - Err(_) => return Ok(None), - }; - let status_pid = status_file.as_ref().map(|status| status.pid); - let pid = pid_from_file.or(status_pid); - let live = pid_file_live || status_pid.map(process_exists).unwrap_or(false) || rpc_available; - if !live { - return Ok(None); - } - - let target_root = normalized_abs_path(output_root)?; - let Some(daemon_root) = status_file - .as_ref() - .map(|status| status.resource_output_root.clone()) - else { - return Ok(Some(DaemonResourceConflict { - pid, - daemon_root: None, - target_root, - })); - }; - - let normalized_daemon_root = normalized_abs_path(&daemon_root)?; - if normalized_daemon_root == target_root { - Ok(Some(DaemonResourceConflict { - pid, - daemon_root: Some(normalized_daemon_root), - target_root, - })) - } else { - Ok(None) - } -} - -fn normalized_abs_path(path: &Path) -> anyhow::Result { - let absolute = if path.is_absolute() { - path.to_path_buf() - } else { - env::current_dir()?.join(path) - }; - Ok(fs::canonicalize(&absolute).unwrap_or_else(|_| lexically_normalize_path(&absolute))) -} - -fn lexically_normalize_path(path: &Path) -> PathBuf { - let mut normalized = PathBuf::new(); - for component in path.components() { - match component { - std::path::Component::CurDir => {} - std::path::Component::ParentDir => { - if !normalized.pop() { - normalized.push(component.as_os_str()); - } - } - _ => normalized.push(component.as_os_str()), - } - } - if normalized.as_os_str().is_empty() { - PathBuf::from(".") - } else { - normalized - } -} - -#[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 { - if state.stop_requested { - return Some(DaemonWake::Stop); - } - if state.reload_requested { - state.reload_requested = false; - state.refresh_requested = false; - state.force_refresh_requested = false; - return Some(DaemonWake::Reload); - } - if state.refresh_requested { - state.refresh_requested = false; - let force = state.force_refresh_requested; - state.force_refresh_requested = false; - return Some(DaemonWake::Refresh { force }); - } - None -} - -#[cfg(unix)] -fn start_daemon_rpc_server( - state_dir: &Path, - control: DaemonControl, - tasks: DaemonTaskContext, -) -> anyhow::Result { - validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?; - fs::create_dir_all(state_dir)?; - let socket_path = daemon_socket_path(state_dir); - if daemon_socket_path_exists(&socket_path)? { - ensure_daemon_socket_not_symlink(&socket_path)?; - match UnixStream::connect(&socket_path) { - Ok(_) => { - return Err(anyhow::anyhow!( - "后台 RPC socket 已被占用:{}", - socket_path.display() - )); - } - Err(_) => { - let _ = fs::remove_file(&socket_path); - } - } - } - - let listener = UnixListener::bind(&socket_path)?; - let server_state_dir = state_dir.to_path_buf(); - thread::Builder::new() - .name("bat-daemon-rpc".to_string()) - .spawn(move || { - for stream in listener.incoming() { - match stream { - Ok(stream) => { - let state_dir = server_state_dir.clone(); - let control = Arc::clone(&control); - let tasks = tasks.clone(); - let _ = thread::Builder::new() - .name("bat-daemon-rpc-client".to_string()) - .spawn(move || { - handle_daemon_rpc_client(stream, state_dir, control, tasks) - }); - } - Err(error) => { - eprintln!("[daemon] RPC socket accept 失败:{error}"); - break; - } - } - } - })?; - - Ok(DaemonRpcServer { socket_path }) -} - -#[cfg(not(unix))] -fn start_daemon_rpc_server( - _state_dir: &Path, - _control: DaemonControl, - _tasks: DaemonTaskContext, -) -> anyhow::Result { - Err(anyhow::anyhow!("daemon RPC 目前只支持 Unix/Linux 平台")) -} - -#[cfg(unix)] -fn handle_daemon_rpc_client( - mut stream: UnixStream, - state_dir: PathBuf, - control: DaemonControl, - tasks: DaemonTaskContext, -) { - let Ok(reader_stream) = stream.try_clone() else { - return; - }; - let mut reader = BufReader::new(reader_stream); - let mut line = String::new(); - loop { - line.clear(); - let bytes_read = match reader.read_line(&mut line) { - Ok(bytes_read) => bytes_read, - Err(error) => { - eprintln!("[daemon] RPC socket 读取失败:{error}"); - return; - } - }; - if bytes_read == 0 { - return; - } - if line.trim().is_empty() { - continue; - } - - let mut notify_stop_after_response = false; - let response = match serde_json::from_str::(&line) { - Ok(request) => { - notify_stop_after_response = 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::( - 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(¶ms) - .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::( - 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(¶ms) - .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::( - 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(¶ms) - .and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)), - ) - } - RPC_METHOD_UNITYFS_PATCH_FIELD => { - let params = match rpc_struct_params::( - 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(¶ms) - .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, -) -> 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, Option)> { - let status_file = read_daemon_status_file(&daemon_status_path(state_dir))?; - let version_state = status_file - .as_ref() - .map(|status| { - status - .resource_output_root - .join("official-version-state.json") - }) - .and_then(|path| read_version_state(&path).ok().flatten()); - Ok((status_file, version_state)) -} - -fn 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 { - 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::().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 { - 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 { - let (status_file, version_state) = read_daemon_resource_state(state_dir)?; - let Some(state) = version_state else { - return Ok(serde_json::json!({ "available": false })); - }; - Ok(serde_json::json!({ - "available": true, - "resource_output_root": status_file.map(|status| status.resource_output_root), - "current": state.current_completed_version, - "in_progress": state.in_progress_version, - "previous": state.previous_available_version, - "failed": state.failed_versions, - "updated_unix_seconds": state.updated_unix_seconds, - })) -} - -/// `catalog.diff`:当前已发布 snapshot 相对上一个可用版本的差异。 -/// -/// 没有上一个版本(或其 snapshot 不可读,见 `previous_snapshot_missing`)时 -/// 按首次观察处理(`base_delta.is_initial = true`)。 -fn build_catalog_diff_report(state_dir: &Path) -> anyhow::Result { - let (_, version_state) = read_daemon_resource_state(state_dir)?; - let current_record = version_state - .as_ref() - .and_then(|state| state.current_completed_version.as_ref()); - let current_snapshot = - current_record.and_then(|record| read_snapshot(&record.snapshot_path).ok().flatten()); - let (Some(current_record), Some(current_snapshot)) = (current_record, current_snapshot) else { - return Ok(serde_json::json!({ "available": false })); - }; - let previous_record = version_state - .as_ref() - .and_then(|state| state.previous_available_version.as_ref()); - let previous_snapshot = - previous_record.and_then(|record| read_snapshot(&record.snapshot_path).ok().flatten()); - let 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(¤t_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 { - 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 { - 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)> { - 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)> { - 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 { - sqlite_file_exists_no_symlink(path, "资源索引数据库") -} - -fn sqlite_file_exists_no_symlink(path: &Path, label: &str) -> anyhow::Result { - 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 { - 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 { - 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::>(); - 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 { - 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::>(); - 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, - })) -} - -/// `translation.tasks`:当前已发布版本的离线翻译任务状态查询。 -fn build_translation_tasks_report( - state_dir: &Path, - query: OfficialTextUnitTaskQuery, - offset: usize, - limit: usize, -) -> anyhow::Result { - let (_, version_state) = read_daemon_resource_state(state_dir)?; - let current = version_state - .as_ref() - .and_then(|state| state.current_completed_version.as_ref()); - let Some(record) = current else { - return Ok(serde_json::json!({ "available": false })); - }; - let task_queue_path = record - .resource_root - .join(bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE); - let task_repository_path = - SqliteTranslationTaskRepository::repository_path(&record.resource_root); - let Some(queue) = bat_infrastructure::read_textunit_task_queue_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_task_queue_path": task_queue_path, - "task_repository_path": task_repository_path, - "task_repository_available": false, - })); - }; - let task_repository_available = - sqlite_file_exists_no_symlink(&task_repository_path, "翻译任务状态数据库")?; - let (total_entries, entries) = if task_repository_available { - query_translation_task_repository(&task_repository_path, &query, offset, limit)? - } else { - let mut queue_query = query.clone(); - queue_query.task_status = None; - queue_query.has_failure_reason = None; - let matches = bat_infrastructure::query_textunit_tasks(&queue, &queue_query); - let persisted = matches - .into_iter() - .cloned() - .map(|task| { - bat_infrastructure::PersistedTranslationTask::from_queued_task( - task, - queue.generated_unix_seconds, - ) - }) - .filter(|task| { - query - .task_status - .as_ref() - .is_none_or(|status| task.task_status.as_str() == status) - }) - .filter(|task| { - query - .has_failure_reason - .is_none_or(|has_reason| task.failure_reason.is_some() == has_reason) - }) - .collect::>(); - let total_entries = persisted.len(); - let entries = persisted - .into_iter() - .skip(offset) - .take(limit) - .collect::>(); - (total_entries as u64, entries) - }; - Ok(serde_json::json!({ - "available": true, - "current_version_id": record.id, - "resource_root": record.resource_root, - "textunit_task_queue_path": task_queue_path, - "task_repository_path": task_repository_path, - "task_repository_available": task_repository_available, - "task_repository_schema_version": bat_infrastructure::TRANSLATION_TASK_SCHEMA_VERSION, - "summary": queue.summary, - "total_entries": total_entries, - "offset": offset, - "limit": limit, - "query": translation_task_query_json(&query), - "entries": entries, - })) -} - -/// `translation.handoff`:当前已发布版本的动态翻译交接视图。 -fn build_translation_handoff_report(state_dir: &Path) -> anyhow::Result { - let (_, version_state) = read_daemon_resource_state(state_dir)?; - let current = version_state - .as_ref() - .and_then(|state| state.current_completed_version.as_ref()); - let Some(record) = current else { - return Ok(serde_json::json!({ "available": false })); - }; - let resource_root = &record.resource_root; - let task_queue_path = resource_root.join(bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE); - let handoff_path = resource_root.join(bat_infrastructure::TRANSLATION_HANDOFF_FILE); - let repository_path = SqliteTranslationTaskRepository::repository_path(resource_root); - let Some(queue) = bat_infrastructure::read_textunit_task_queue_at(resource_root) - .map_err(anyhow::Error::msg)? - else { - return Ok(serde_json::json!({ - "available": false, - "current_version_id": record.id, - "resource_root": resource_root, - "textunit_task_queue_path": task_queue_path, - "translation_handoff_path": handoff_path, - "task_repository_path": repository_path, - })); - }; - let task_repository_available = - sqlite_file_exists_no_symlink(&repository_path, "翻译任务状态数据库")?; - let tasks = if task_repository_available { - 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}"))?; - repository - .list(&OfficialTextUnitTaskQuery::default()) - .await - .map_err(|error| anyhow::anyhow!("{error}")) - })? - } else { - queue - .tasks - .iter() - .cloned() - .map(|task| { - bat_infrastructure::PersistedTranslationTask::from_queued_task( - task, - queue.generated_unix_seconds, - ) - }) - .collect::>() - }; - let handoff = bat_infrastructure::build_translation_handoff(&queue, &tasks); - let handoff_file_available = sqlite_file_exists_no_symlink(&handoff_path, "翻译 handoff")?; - Ok(serde_json::json!({ - "available": true, - "current_version_id": record.id, - "resource_root": resource_root, - "textunit_task_queue_path": task_queue_path, - "translation_handoff_path": handoff_path, - "translation_handoff_file_available": handoff_file_available, - "task_repository_path": repository_path, - "task_repository_available": task_repository_available, - "task_repository_schema_version": bat_infrastructure::TRANSLATION_TASK_SCHEMA_VERSION, - "handoff_schema_version": bat_infrastructure::TRANSLATION_HANDOFF_SCHEMA_VERSION, - "handoff": handoff, - })) -} - -/// `translation.task.update`:写入 provider worker 的可回查状态。 -fn update_translation_task_status_report( - state_dir: &Path, - params: Option<&serde_json::Value>, -) -> anyhow::Result { - let task_id = rpc_string_param(params, "task_id") - .ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 task_id"))?; - let status_label = rpc_string_param(params, "status") - .ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 status"))?; - let status = TranslationTaskStatus::parse(status_label) - .ok_or_else(|| anyhow::anyhow!("不支持的翻译任务 worker 状态:{status_label}"))?; - let failure_reason = rpc_string_param(params, "failure_reason") - .or_else(|| rpc_string_param(params, "reason")) - .map(str::to_string); - let provider_run_id = rpc_string_param(params, "provider_run_id").map(str::to_string); - let (_, version_state) = read_daemon_resource_state(state_dir)?; - let current = version_state - .as_ref() - .and_then(|state| state.current_completed_version.as_ref()) - .ok_or_else(|| anyhow::anyhow!("没有可更新翻译任务的当前官方 release"))?; - let repository_path = SqliteTranslationTaskRepository::repository_path(¤t.resource_root); - if !sqlite_file_exists_no_symlink(&repository_path, "翻译任务状态数据库")? { - return Err(anyhow::anyhow!( - "翻译任务状态数据库不存在:{}", - repository_path.display() - )); - } - - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - let task = runtime.block_on(async { - let repository = SqliteTranslationTaskRepository::open(&repository_path) - .await - .map_err(|error| anyhow::anyhow!("{error}"))?; - repository - .update_status(task_id, status, failure_reason, provider_run_id) - .await - .map_err(|error| anyhow::anyhow!("{error}")) - })?; - Ok(serde_json::json!({ - "available": true, - "current_version_id": current.id, - "task_repository_path": repository_path, - "entry": task, - })) -} - -fn textunit_query_json(query: &OfficialTextUnitQuery) -> serde_json::Value { - serde_json::json!({ - "destination": query.destination.clone(), - "path_pattern": query.path_pattern.clone(), - "archive_entry": query.archive_entry.clone(), - "path_id": query.path_id, - "class_id": query.class_id, - "field_path": query.field_path.clone(), - "format": query.format.clone(), - }) -} - -fn translation_task_query_json(query: &OfficialTextUnitTaskQuery) -> serde_json::Value { - serde_json::json!({ - "task_id": query.task_id.clone(), - "official_release_id": query.official_release_id.clone(), - "destination": query.destination.clone(), - "path_pattern": query.path_pattern.clone(), - "archive_entry": query.archive_entry.clone(), - "status": query.status.clone(), - "task_status": query.task_status.clone(), - "parse_status": query.parse_status.clone(), - "text_unit_format": query.text_unit_format.clone(), - "has_reason": query.has_reason, - "has_failure_reason": query.has_failure_reason, - }) -} - -/// `localized.status`:当前官方版本对应的汉化 release 状态。 -fn build_localized_status_report( - state_dir: &Path, - base_config: &OfficialUpdateConfig, -) -> anyhow::Result { - 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(¤t_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, result: serde_json::Value) -> JsonRpcResponse { - JsonRpcResponse { - jsonrpc: "2.0", - id, - result: Some(result), - error: None, - } -} - -fn json_rpc_error(id: Option, code: i32, message: String) -> JsonRpcResponse { - JsonRpcResponse { - jsonrpc: "2.0", - id, - result: None, - error: Some(JsonRpcError { code, message }), - } -} - -fn rpc_task_id_param(request: &JsonRpcRequest) -> &str { - request - .params - .as_ref() - .and_then(|params| params.get("task_id")) - .and_then(serde_json::Value::as_str) - .unwrap_or_default() -} - -fn rpc_bool_param(params: Option<&serde_json::Value>, key: &str) -> Option { - params - .and_then(|params| params.get(key)) - .and_then(serde_json::Value::as_bool) -} - -fn rpc_struct_params( - params: Option<&serde_json::Value>, - method: &'static str, -) -> Result { - 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> { - 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::() - .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> { - 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 { - let normalized = value - .chars() - .filter(|ch| !matches!(ch, '_' | '-' | ' ')) - .collect::() - .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 { - let normalized = value - .chars() - .filter(|ch| !matches!(ch, '_' | '-' | ' ')) - .collect::() - .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 { - 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 { - let tail = params - .and_then(|params| params.get("tail")) - .and_then(serde_json::Value::as_u64) - .unwrap_or(default as u64); - let tail = usize::try_from(tail)?; - if tail == 0 { - return Err(anyhow::anyhow!("tail 必须大于 0")); - } - Ok(tail) -} - -#[cfg(unix)] -fn daemon_rpc_call( - state_dir: &Path, - method: &str, - params: Option, -) -> anyhow::Result { - let socket_path = daemon_socket_path(state_dir); - ensure_daemon_socket_not_symlink(&socket_path)?; - let mut stream = UnixStream::connect(&socket_path).map_err(|error| { - anyhow::anyhow!("无法连接后台 RPC socket {}:{error}", socket_path.display()) - })?; - let request = serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": method, - "params": params.unwrap_or(serde_json::Value::Null), - }); - serde_json::to_writer(&mut stream, &request)?; - stream.write_all(b"\n")?; - stream.flush()?; - - let mut reader = BufReader::new(stream); - let mut line = String::new(); - reader.read_line(&mut line)?; - if line.trim().is_empty() { - return Err(anyhow::anyhow!("后台 RPC socket 未返回响应")); - } - let response: JsonRpcClientResponse = serde_json::from_str(&line)?; - if let Some(error) = response.error { - return Err(anyhow::anyhow!( - "后台 RPC {} 传输层失败:{} ({})", - method, - error.message, - error.code - )); - } - // 解包应用层 envelope:检查 ok,成功返回 data,失败把 envelope.error 转为 Err。 - let envelope = response - .result - .ok_or_else(|| anyhow::anyhow!("后台 RPC {method} 未返回 result"))?; - let ok = envelope - .get("ok") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - if !ok { - let error = envelope.get("error"); - let code = error - .and_then(|error| error.get("code")) - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - let message = error - .and_then(|error| error.get("message")) - .and_then(serde_json::Value::as_str) - .unwrap_or("未知错误"); - return Err(anyhow::anyhow!( - "后台 RPC {method} 失败:[{code}] {message}" - )); - } - Ok(envelope - .get("data") - .cloned() - .unwrap_or(serde_json::Value::Null)) -} - -#[cfg(not(unix))] -fn daemon_rpc_call( - _state_dir: &Path, - _method: &str, - _params: Option, -) -> anyhow::Result { - Err(anyhow::anyhow!("daemon RPC 目前只支持 Unix/Linux 平台")) -} - -#[cfg(unix)] -fn daemon_rpc_available(state_dir: &Path) -> bool { - let socket_path = daemon_socket_path(state_dir); - ensure_daemon_socket_not_symlink(&socket_path).is_ok() - && UnixStream::connect(socket_path).is_ok() -} - -#[cfg(not(unix))] -fn daemon_rpc_available(_state_dir: &Path) -> bool { - false -} - -#[cfg(unix)] -fn ensure_daemon_socket_not_symlink(socket_path: &Path) -> anyhow::Result<()> { - match fs::symlink_metadata(socket_path) { - Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow::anyhow!( - "后台 RPC socket 不能是 symlink:{}", - socket_path.display() - )), - Ok(_) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error.into()), - } -} - -#[cfg(unix)] -fn daemon_socket_path_exists(socket_path: &Path) -> anyhow::Result { - path_exists_no_follow(socket_path).map_err(|error| { - anyhow::anyhow!( - "后台 RPC socket 路径检查失败 {}:{error}", - socket_path.display() - ) - }) -} - -#[cfg(not(unix))] -fn daemon_socket_path_exists(socket_path: &Path) -> anyhow::Result { - path_exists_no_follow(socket_path) -} - -fn path_exists_no_follow(path: &Path) -> anyhow::Result { - match fs::symlink_metadata(path) { - Ok(_) => Ok(true), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(error.into()), - } -} - -#[derive(Debug, Serialize)] -struct DaemonStartReport { - status: &'static str, - message: &'static str, - pid: u32, - resource_output_root: PathBuf, - 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, - resource_output_root: Option, - localized_output_root: Option, - state_dir: PathBuf, - pid_path: PathBuf, - status_path: PathBuf, - socket_path: PathBuf, - rpc_available: bool, - stale_pid_file: bool, - stale_socket: bool, - log_path: Option, - structured_log_path: Option, - rotated_structured_log_paths: Vec, - started_unix_seconds: Option, - updated_unix_seconds: Option, - last_success_unix_seconds: Option, - next_check_unix_seconds: Option, - daemon_state: Option, - last_update_status: Option, - last_error: Option, - next_retry_seconds: Option, - current_stage: Option, - status_code: Option, - current_message: Option, - download_progress: Option, - version_state_path: Option, - version_state: Option, - pending_scheduled_force: Option, - next_forced_refresh_unix_seconds: Option, - command: Option>, -} - -#[derive(Debug, Serialize)] -struct DaemonStopReport { - status: &'static str, - message: &'static str, - stopped: bool, - pid: Option, - state_dir: PathBuf, - pid_path: PathBuf, - socket_path: PathBuf, -} - -fn run_daemon_start(options: CliOptions) -> anyhow::Result<()> { - if options.config.dry_run { - return Err(anyhow::anyhow!("daemon 后台模式不能和 --dry-run 同时使用")); - } - if options.watch { - return Err(anyhow::anyhow!( - "--daemon 会自动启动 watch 模式,不要同时传 --watch" - )); - } - - let report = start_daemon_with_options(&options)?; - print_report(options.output_format, &report)?; - Ok(()) -} - -fn start_daemon_with_options(options: &CliOptions) -> anyhow::Result { - let resource_output_root = normalized_abs_path(&options.config.output_root)?; - let 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 { - 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, - proxy_url: Option, - message: &'static str, -) -> anyhow::Result { - validate_runtime_state_dir(&state_dir).map_err(anyhow::Error::msg)?; - fs::create_dir_all(&state_dir)?; - let pid_path = daemon_pid_path(&state_dir); - let status_path = daemon_status_path(&state_dir); - let log_path = daemon_log_path(&state_dir); - let structured_log_path = daemon_structured_log_path(&state_dir); - let socket_path = daemon_socket_path(&state_dir); - - match classify_pid_lock_file(&pid_path)? { - PidLockState::Active(existing_pid) => { - return Err(anyhow::anyhow!( - "官方同步后台进程已经在运行,pid={existing_pid}" - )); - } - PidLockState::StalePid(_) | PidLockState::Corrupt => { - let _ = fs::remove_file(&pid_path); - let _ = fs::remove_file(&socket_path); - } - PidLockState::Missing => {} - } - - // 代理凭据经环境变量下传子进程(/proc//environ 仅属主可读), - // 并写入专用 0600 文件供后续 restart/reload 复用;无代理时清除残留凭据。 - match proxy_url.as_deref() { - Some(url) => write_daemon_proxy_secret(&state_dir, url)?, - None => clear_daemon_proxy_secret(&state_dir), - } - - let executable = env::current_exe()?; - let log = - open_append_file(&log_path, PRIVATE_FILE_MODE, "后台日志").map_err(anyhow::Error::msg)?; - let log_for_stdout = log.try_clone()?; - let mut command = Command::new(&executable); - command - .args(&args) - .stdin(Stdio::null()) - .stdout(Stdio::from(log_for_stdout)) - .stderr(Stdio::from(log)); - match proxy_url.as_deref() { - Some(url) => { - command.env(PROXY_URL_ENV_VAR, url); - } - None => { - command.env_remove(PROXY_URL_ENV_VAR); - } - } - configure_daemon_command(&mut command); - let child = match command.spawn() { - Ok(child) => child, - Err(error) => { - // 子进程未能启动,清除刚写入的代理凭据,避免残留。 - clear_daemon_proxy_secret(&state_dir); - return Err(error.into()); - } - }; - let pid = child.id(); - - if let Err(error) = write_file_atomic( - &pid_path, - pid.to_string().as_bytes(), - PRIVATE_FILE_MODE, - "后台 PID 文件", - ) - .map_err(anyhow::Error::msg) - { - let _ = terminate_process(pid); - clear_daemon_proxy_secret(&state_dir); - return Err(error); - } - let mut command = Vec::with_capacity(args.len() + 1); - command.push(executable.to_string_lossy().to_string()); - command.extend(args); - let status = DaemonStatusFile { - version: DAEMON_STATUS_VERSION, - pid, - state: "started".to_string(), - resource_output_root: resource_output_root.clone(), - 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 { - let pid_path = daemon_pid_path(state_dir); - let pid_state = classify_pid_lock_file(&pid_path)?; - let (pid, running) = match pid_state { - PidLockState::Missing => { - return Ok(DaemonStopReport { - status: "not_running", - message: "后台进程当前未运行", - stopped: false, - pid: None, - state_dir: state_dir.to_path_buf(), - pid_path, - socket_path: daemon_socket_path(state_dir), - }); - } - PidLockState::Corrupt => { - let _ = fs::remove_file(&pid_path); - let _ = fs::remove_file(daemon_socket_path(state_dir)); - return Ok(DaemonStopReport { - status: "stale_pid_removed", - message: "已清理无效的后台 PID 文件", - stopped: false, - pid: None, - state_dir: state_dir.to_path_buf(), - pid_path, - socket_path: daemon_socket_path(state_dir), - }); - } - PidLockState::StalePid(pid) => (pid, false), - PidLockState::Active(pid) => { - terminate_process(pid)?; - if !wait_for_process_exit(pid, Duration::from_secs(5)) { - return Err(anyhow::anyhow!("后台进程 pid={pid} 在 5 秒内未停止")); - } - (pid, true) - } - }; - let _ = fs::remove_file(&pid_path); - let _ = fs::remove_file(daemon_socket_path(state_dir)); - - Ok(DaemonStopReport { - status: if running { - "stopped" - } else { - "stale_pid_removed" - }, - message: if running { - "后台进程已停止" - } else { - "已清理失效的后台 PID 文件" - }, - stopped: running, - pid: Some(pid), - state_dir: state_dir.to_path_buf(), - pid_path, - socket_path: daemon_socket_path(state_dir), - }) -} - -fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result { - let pid_path = daemon_pid_path(state_dir); - let status_path = daemon_status_path(state_dir); - let socket_path = daemon_socket_path(state_dir); - let status_file = read_daemon_status_file(&status_path)?; - let pid_file_state = classify_pid_lock_file(&pid_path)?; - let pid_file_pid = match pid_file_state { - PidLockState::Active(pid) | PidLockState::StalePid(pid) => Some(pid), - PidLockState::Missing | PidLockState::Corrupt => None, - }; - let pid = pid_file_pid.or_else(|| status_file.as_ref().map(|status| status.pid)); - let running = pid.map(process_exists).unwrap_or(false); - let rpc_available = daemon_rpc_available(state_dir); - let stale_pid_file = matches!( - pid_file_state, - PidLockState::StalePid(_) | PidLockState::Corrupt - ); - let stale_socket = daemon_socket_path_exists(&socket_path)? && !rpc_available; - let default_log_path = daemon_log_path(state_dir); - let default_log_exists = path_exists_no_follow(&default_log_path)?; - let structured_log_path = status_file - .as_ref() - .and_then(|status| status.structured_log_path.clone()) - .unwrap_or_else(|| daemon_structured_log_path(state_dir)); - let structured_log_exists = path_exists_no_follow(&structured_log_path)?; - let rotated_structured_log_paths = rotated_structured_log_paths(&structured_log_path); - let version_state_path = status_file.as_ref().map(|status| { - status - .resource_output_root - .join("official-version-state.json") - }); - let version_state = version_state_path - .as_ref() - .and_then(|path| read_version_state(path).ok().flatten()); - - Ok(DaemonStatusReport { - status: if running { "running" } else { "stopped" }, - message: if running { - "后台进程正在运行" - } else if stale_pid_file || stale_socket { - "后台进程当前未运行,但检测到失效 PID/socket;可执行 clean-stable 清理" - } else { - "后台进程当前未运行" - }, - running, - pid, - resource_output_root: status_file - .as_ref() - .map(|status| status.resource_output_root.clone()), - 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) -> Vec { - let mut redacted = command; - for index in 0..redacted.len() { - if redacted[index] == "--proxy" { - if let Some(value) = redacted.get_mut(index + 1) { - *value = redact_proxy_url(value); - } - } - } - redacted -} - -#[derive(Debug, Serialize)] -struct DaemonControlReport { - command: &'static str, - status: &'static str, - message: &'static str, - strategy: &'static str, - previous_pid: Option, - pid: u32, - state_dir: PathBuf, - resource_output_root: PathBuf, - log_path: PathBuf, - socket_path: PathBuf, -} - -fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyhow::Result<()> { - let has_explicit_options = options.sync_option_explicit - || options.output_explicit - || options.proxy_option_explicit - || tools_are_non_default(&options.config, &options.env_baseline_config); - if command_name == "reload" && !has_explicit_options && daemon_rpc_available(&options.state_dir) - { - let report = daemon_rpc_call(&options.state_dir, RPC_METHOD_RELOAD, None)?; - print_json_value(options.output_format, &report)?; - return Ok(()); - } - - let status_file = read_daemon_status_file(&daemon_status_path(&options.state_dir))?; - // 在停止旧后台前读取已保存的代理凭据,供复用型重启还原。 - let saved_proxy_url = read_daemon_proxy_secret(&options.state_dir)?; - let status_report = build_daemon_status_report(&options.state_dir)?; - let previous_pid = status_report.pid.filter(|pid| process_exists(*pid)); - - if previous_pid.is_some() { - if daemon_rpc_available(&options.state_dir) - && daemon_rpc_call(&options.state_dir, RPC_METHOD_STOP, None).is_ok() - { - if let Some(pid) = previous_pid { - let _ = wait_for_rpc_stop_or_terminate(pid, Duration::from_secs(10))?; - } - let _ = fs::remove_file(daemon_pid_path(&options.state_dir)); - let _ = fs::remove_file(daemon_socket_path(&options.state_dir)); - } else { - let _ = stop_daemon_inner(&options.state_dir)?; - } - } else if read_pid_file(&daemon_pid_path(&options.state_dir))?.is_some() { - let _ = stop_daemon_inner(&options.state_dir)?; - } - - let (state_dir, resource_output_root, 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::>(); - 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 { - 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) -> anyhow::Result, -) -> 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 run_readonly_query_command(options: &CliOptions) -> anyhow::Result<()> { - run_readonly_query_command_with_rpc(options, daemon_rpc_available, daemon_rpc_call) -} - -fn run_write_patch_command(options: &CliOptions) -> anyhow::Result<()> { - match options.command { - CliCommand::PatchApply => { - let params = patch_apply_params_from_options(options)?; - let report = apply_patch_file(¶ms)?; - print_report(options.output_format, &report) - } - CliCommand::UnityFsPatchTextAsset => { - let params = unityfs_text_asset_params_from_options(options)?; - let report = apply_unityfs_text_asset_patch_file(¶ms)?; - print_report(options.output_format, &report) - } - CliCommand::UnityFsPatchStringField => { - let params = unityfs_string_field_params_from_options(options)?; - let report = apply_unityfs_string_field_patch_file(¶ms)?; - print_report(options.output_format, &report) - } - CliCommand::UnityFsPatchField => { - let params = unityfs_field_params_from_options(options)?; - let report = apply_unityfs_field_patch_file(¶ms)?; - print_report(options.output_format, &report) - } - _ => Err(anyhow::anyhow!("不是写入 patch 命令")), - } -} - -fn run_readonly_query_command_with_rpc( - options: &CliOptions, - rpc_available: impl Fn(&Path) -> bool, - rpc_call: impl Fn(&Path, &str, Option) -> anyhow::Result, -) -> anyhow::Result<()> { - let method = readonly_query_rpc_method(options.command) - .ok_or_else(|| anyhow::anyhow!("不是只读查询命令"))?; - if rpc_available(&options.state_dir) && !readonly_query_requires_local_config(options) { - let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; - let report = rpc_call( - &options.state_dir, - method, - readonly_query_rpc_params(options), - )?; - print_json_value(options.output_format, &report)?; - return Ok(()); - } - - let report = build_readonly_query_report(options, method)?; - print_json_value(options.output_format, &report) -} - -fn readonly_query_rpc_method(command: CliCommand) -> Option<&'static str> { - match command { - CliCommand::ParseStatus => Some(RPC_METHOD_PARSE_STATUS), - CliCommand::ParseTextUnits => Some(RPC_METHOD_PARSE_TEXT_UNITS), - CliCommand::ParseErrors => Some(RPC_METHOD_PARSE_ERRORS), - CliCommand::TranslationTasks => Some(RPC_METHOD_TRANSLATION_TASKS), - CliCommand::TranslationHandoff => Some(RPC_METHOD_TRANSLATION_HANDOFF), - CliCommand::LocalizedStatus => Some(RPC_METHOD_LOCALIZED_STATUS), - CliCommand::ResourceIndex => Some(RPC_METHOD_RESOURCE_INDEX), - _ => None, - } -} - -fn readonly_query_rpc_params(options: &CliOptions) -> Option { - let mut params = serde_json::Map::new(); - match options.command { - CliCommand::ResourceIndex - | CliCommand::ParseTextUnits - | CliCommand::ParseErrors - | CliCommand::TranslationTasks => { - params.insert( - "offset".to_string(), - serde_json::json!(options.query_offset), - ); - params.insert("limit".to_string(), serde_json::json!(options.query_limit)); - } - _ => return None, - } - match options.command { - CliCommand::ResourceIndex => { - if let Some(resource_type) = options.query_resource_type { - params.insert( - "resource_type".to_string(), - serde_json::json!(resource_type_rpc_label(resource_type)), - ); - } - if let Some(hash) = options.query_hash.as_ref() { - params.insert("hash".to_string(), serde_json::json!(hash)); - } - if let Some(path_pattern) = options.query_path_pattern.as_ref() { - params.insert("path_pattern".to_string(), serde_json::json!(path_pattern)); - } - if let Some(release_id) = options.query_official_release_id.as_ref() { - params.insert( - "official_release_id".to_string(), - serde_json::json!(release_id), - ); - } - if let Some(platform) = options.query_platform.as_ref() { - params.insert("platform".to_string(), serde_json::json!(platform)); - } - if let Some(destination) = options.query_destination.as_ref() { - params.insert("destination".to_string(), serde_json::json!(destination)); - } - if let Some(bundle_path) = options.query_bundle_path.as_ref() { - params.insert("bundle_path".to_string(), serde_json::json!(bundle_path)); - } - if let Some(archive_entry) = options.query_archive_entry.as_ref() { - params.insert( - "archive_entry".to_string(), - serde_json::json!(archive_entry), - ); - } - if let Some(parse_status) = options.query_parse_status.as_ref() { - params.insert("parse_status".to_string(), serde_json::json!(parse_status)); - } - if let Some(format) = options.query_format.as_ref() { - params.insert("text_unit_format".to_string(), serde_json::json!(format)); - } - } - CliCommand::ParseTextUnits | CliCommand::ParseErrors => { - if let Some(destination) = options.query_destination.as_ref() { - params.insert("destination".to_string(), serde_json::json!(destination)); - } - if let Some(path_pattern) = options.query_path_pattern.as_ref() { - params.insert("path_pattern".to_string(), serde_json::json!(path_pattern)); - } - if let Some(archive_entry) = options.query_archive_entry.as_ref() { - params.insert( - "archive_entry".to_string(), - serde_json::json!(archive_entry), - ); - } - if let Some(path_id) = options.query_path_id { - params.insert("path_id".to_string(), serde_json::json!(path_id)); - } - if let Some(class_id) = options.query_class_id { - params.insert("class_id".to_string(), serde_json::json!(class_id)); - } - if let Some(field_path) = options.query_field_path.as_ref() { - params.insert("field_path".to_string(), serde_json::json!(field_path)); - } - if let Some(format) = options.query_format.as_ref() { - params.insert("format".to_string(), serde_json::json!(format)); - } - } - CliCommand::TranslationTasks => { - if let Some(task_id) = options.query_task_id.as_ref() { - params.insert("task_id".to_string(), serde_json::json!(task_id)); - } - if let Some(release_id) = options.query_official_release_id.as_ref() { - params.insert( - "official_release_id".to_string(), - serde_json::json!(release_id), - ); - } - if let Some(destination) = options.query_destination.as_ref() { - params.insert("destination".to_string(), serde_json::json!(destination)); - } - if let Some(path_pattern) = options.query_path_pattern.as_ref() { - params.insert("path_pattern".to_string(), serde_json::json!(path_pattern)); - } - if let Some(archive_entry) = options.query_archive_entry.as_ref() { - params.insert( - "archive_entry".to_string(), - serde_json::json!(archive_entry), - ); - } - if let Some(status) = options.query_task_status.as_ref() { - params.insert("status".to_string(), serde_json::json!(status)); - } - if let Some(status) = options.query_worker_status.as_ref() { - params.insert("worker_status".to_string(), serde_json::json!(status)); - } - if let Some(parse_status) = options.query_parse_status.as_ref() { - params.insert("parse_status".to_string(), serde_json::json!(parse_status)); - } - if let Some(format) = options.query_format.as_ref() { - params.insert("text_unit_format".to_string(), serde_json::json!(format)); - } - if let Some(has_reason) = options.query_has_reason { - params.insert("has_reason".to_string(), serde_json::json!(has_reason)); - } - if let Some(has_failure_reason) = options.query_has_failure_reason { - params.insert( - "has_failure_reason".to_string(), - serde_json::json!(has_failure_reason), - ); - } - } - _ => {} - } - Some(serde_json::Value::Object(params)) -} - -fn readonly_query_requires_local_config(options: &CliOptions) -> bool { - matches!(options.command, CliCommand::ResourceIndex) - && options.config.import_resource_repository_path.is_some() -} - -fn build_readonly_query_report( - options: &CliOptions, - method: &str, -) -> anyhow::Result { - match method { - RPC_METHOD_PARSE_STATUS => build_parse_status_report(&options.state_dir), - RPC_METHOD_PARSE_TEXT_UNITS => build_parse_text_units_report( - &options.state_dir, - textunit_query_from_options(options), - options.query_offset, - options.query_limit, - ), - RPC_METHOD_PARSE_ERRORS => build_parse_errors_report( - &options.state_dir, - textunit_query_from_options(options), - options.query_offset, - options.query_limit, - ), - RPC_METHOD_TRANSLATION_TASKS => build_translation_tasks_report( - &options.state_dir, - translation_task_query_from_options(options), - options.query_offset, - options.query_limit, - ), - RPC_METHOD_TRANSLATION_HANDOFF => build_translation_handoff_report(&options.state_dir), - RPC_METHOD_LOCALIZED_STATUS => { - build_localized_status_report(&options.state_dir, &options.config) - } - RPC_METHOD_RESOURCE_INDEX => build_resource_index_report( - &options.state_dir, - &options.config, - resource_index_query_from_options(options), - options.query_offset, - options.query_limit, - ), - _ => Err(anyhow::anyhow!("不支持的只读查询方法:{method}")), - } -} - -fn validate_readonly_query_options(options: &CliOptions) -> anyhow::Result<()> { - let has_resource_index_only_filter = options.query_resource_type.is_some() - || options.query_hash.is_some() - || options.query_platform.is_some() - || options.query_bundle_path.is_some(); - let has_parse_object_filter = options.query_path_id.is_some() - || options.query_class_id.is_some() - || options.query_field_path.is_some(); - let has_translation_task_filter = options.query_task_id.is_some() - || options.query_task_status.is_some() - || options.query_worker_status.is_some() - || options.query_has_reason.is_some() - || options.query_has_failure_reason.is_some(); - - match options.command { - CliCommand::ResourceIndex => { - if has_parse_object_filter || has_translation_task_filter { - return Err(anyhow::anyhow!( - "--path-id/--class-id/--field-path 只适用于 parse-text-units 或 parse-errors;--task-id/--task-status/--worker-status/--has-reason/--has-failure-reason 只适用于 translation-tasks" - )); - } - } - CliCommand::ParseTextUnits | CliCommand::ParseErrors => { - if has_resource_index_only_filter - || has_translation_task_filter - || options.query_official_release_id.is_some() - || options.query_parse_status.is_some() - { - return Err(anyhow::anyhow!( - "--resource-type/--hash/--release-id/--platform/--bundle-path/--parse-status 只适用于 resource-index 或 translation-tasks;--task-id/--task-status/--worker-status/--has-reason/--has-failure-reason 只适用于 translation-tasks" - )); - } - } - CliCommand::TranslationTasks => { - if has_resource_index_only_filter || has_parse_object_filter { - return Err(anyhow::anyhow!( - "--resource-type/--hash/--platform/--bundle-path 只适用于 resource-index;--path-id/--class-id/--field-path 只适用于 parse-text-units 或 parse-errors" - )); - } - } - CliCommand::TranslationHandoff if options.query_option_explicit => { - return Err(anyhow::anyhow!( - "translation-handoff 不接受查询过滤参数;请使用 translation-tasks 查询单项任务" - )); - } - CliCommand::ParseStatus | CliCommand::LocalizedStatus if options.query_option_explicit => { - return Err(anyhow::anyhow!( - "查询过滤参数只适用于 resource-index、parse-text-units、parse-errors 或 translation-tasks" - )); - } - _ => {} - } - Ok(()) -} - -fn is_write_patch_command(command: CliCommand) -> bool { - matches!( - command, - CliCommand::PatchApply - | CliCommand::UnityFsPatchTextAsset - | CliCommand::UnityFsPatchStringField - | CliCommand::UnityFsPatchField - ) -} - -fn validate_write_patch_options(options: &CliOptions) -> anyhow::Result<()> { - match options.command { - CliCommand::PatchApply => { - let _ = patch_apply_params_from_options(options)?; - reject_unityfs_write_options(options, "patch-apply")?; - } - CliCommand::UnityFsPatchTextAsset => { - let _ = unityfs_text_asset_params_from_options(options)?; - reject_patch_apply_options(options, "unityfs-patch-text-asset")?; - if options.unityfs_field_path.is_some() - || options.unityfs_replacement_text.is_some() - || options.unityfs_expected_value.is_some() - { - return Err(anyhow::anyhow!( - "unityfs-patch-text-asset 不接受 --field-path、--string-field-path、--replacement-text 或 --expected-value" - )); - } - } - CliCommand::UnityFsPatchStringField => { - let _ = unityfs_string_field_params_from_options(options)?; - reject_patch_apply_options(options, "unityfs-patch-string-field")?; - if options.unityfs_expected_name.is_some() { - return Err(anyhow::anyhow!( - "unityfs-patch-string-field 不接受 --expected-name" - )); - } - } - CliCommand::UnityFsPatchField => { - let _ = unityfs_field_params_from_options(options)?; - reject_patch_apply_options(options, "unityfs-patch-field")?; - if options.unityfs_expected_name.is_some() - || options.unityfs_replacement_text.is_some() - || options.unityfs_expected_value.is_some() - { - return Err(anyhow::anyhow!( - "unityfs-patch-field 不接受 --expected-name、--replacement-text 或 --expected-value;请使用 --replacement-json / --expected-json" - )); - } - } - _ => {} - } - Ok(()) -} - -fn patch_apply_params_from_options(options: &CliOptions) -> anyhow::Result { - Ok(PatchApplyParams { - kind: require_cli_option(options.patch_kind, "--patch-kind")?, - source_path: require_cli_option(options.patch_source_path.clone(), "--source-file")?, - patch_path: require_cli_option(options.patch_patch_path.clone(), "--patch-file")?, - target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?, - }) -} - -fn unityfs_text_asset_params_from_options( - options: &CliOptions, -) -> anyhow::Result { - Ok(UnityFsTextAssetPatchParams { - bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?, - serialized_file_path: require_cli_option( - options.unityfs_serialized_file_path.clone(), - "--serialized-file", - )?, - path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?, - replacement_path: require_cli_option( - options.unityfs_replacement_path.clone(), - "--replacement-file", - )?, - target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?, - expected_name: options.unityfs_expected_name.clone(), - }) -} - -fn unityfs_string_field_params_from_options( - options: &CliOptions, -) -> anyhow::Result { - let has_replacement_text = options.unityfs_replacement_text.is_some(); - let has_replacement_path = options.unityfs_replacement_path.is_some(); - if has_replacement_text == has_replacement_path { - return Err(anyhow::anyhow!( - "unityfs-patch-string-field 必须且只能指定 --replacement-text 或 --replacement-file 其中一个" - )); - } - Ok(UnityFsStringFieldPatchParams { - bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?, - serialized_file_path: require_cli_option( - options.unityfs_serialized_file_path.clone(), - "--serialized-file", - )?, - path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?, - field_path: require_cli_option( - options.unityfs_field_path.clone(), - "--field-path/--string-field-path", - )?, - replacement_text: options.unityfs_replacement_text.clone(), - replacement_path: options.unityfs_replacement_path.clone(), - target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?, - expected_value: options.unityfs_expected_value.clone(), - }) -} - -fn unityfs_field_params_from_options( - options: &CliOptions, -) -> anyhow::Result { - if options.unityfs_replacement_path.is_some() { - return Err(anyhow::anyhow!( - "unityfs-patch-field 不接受 --replacement-file;请使用 --replacement-json" - )); - } - Ok(UnityFsFieldPatchParams { - bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?, - serialized_file_path: require_cli_option( - options.unityfs_serialized_file_path.clone(), - "--serialized-file", - )?, - path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?, - field_path: require_cli_option( - options.unityfs_field_path.clone(), - "--field-path/--string-field-path", - )?, - replacement: require_cli_option( - options.unityfs_replacement_value.clone(), - "--replacement-json", - )?, - target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?, - expected_value: options.unityfs_expected_semantic_value.clone(), - }) -} - -fn require_cli_option(value: Option, name: &str) -> anyhow::Result { - value.ok_or_else(|| anyhow::anyhow!("缺少必要参数 {name}")) -} - -fn reject_patch_apply_options(options: &CliOptions, command: &str) -> anyhow::Result<()> { - if options.patch_kind.is_some() - || options.patch_source_path.is_some() - || options.patch_patch_path.is_some() - { - return Err(anyhow::anyhow!( - "{command} 不接受 --patch-kind、--source-file 或 --patch-file" - )); - } - Ok(()) -} - -fn reject_unityfs_write_options(options: &CliOptions, command: &str) -> anyhow::Result<()> { - if options.unityfs_bundle_path.is_some() - || options.unityfs_serialized_file_path.is_some() - || options.unityfs_path_id.is_some() - || options.unityfs_field_path.is_some() - || options.unityfs_replacement_path.is_some() - || options.unityfs_replacement_text.is_some() - || options.unityfs_expected_name.is_some() - || options.unityfs_expected_value.is_some() - || options.unityfs_replacement_value.is_some() - || options.unityfs_expected_semantic_value.is_some() - { - return Err(anyhow::anyhow!( - "{command} 不接受 UnityFS 写入参数;请改用 unityfs-patch-* 命令" - )); - } - 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(format: OutputFormat, report: &T) -> anyhow::Result<()> -where - T: Serialize + HumanReport, -{ - match format { - OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?), - OutputFormat::Human => report.print_human()?, - } - Ok(()) -} - -fn print_json_value(format: OutputFormat, value: &serde_json::Value) -> anyhow::Result<()> { - match format { - OutputFormat::Json => println!("{}", serde_json::to_string_pretty(value)?), - OutputFormat::Human => print_human_json_value(value)?, - } - Ok(()) -} - -trait HumanReport { - fn print_human(&self) -> anyhow::Result<()>; -} - -fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> { - if value.get("running").is_some() && value.get("state_dir").is_some() { - print_title("后台状态"); - print_json_field(value, "status", "状态"); - print_json_field(value, "message", "消息"); - print_json_field(value, "running", "运行中"); - print_json_field(value, "pid", "PID"); - print_json_field(value, "daemon_state", "后台状态"); - print_json_field(value, "rpc_available", "RPC 可用"); - print_json_field(value, "stale_pid_file", "失效 PID"); - print_json_field(value, "stale_socket", "失效 socket"); - print_json_field(value, "last_update_status", "上次同步"); - print_json_field(value, "last_success_unix_seconds", "最后成功时间"); - print_json_field(value, "last_error", "上次错误"); - print_json_field(value, "next_retry_seconds", "下次重试秒数"); - print_json_field(value, "next_check_unix_seconds", "下次检查时间"); - print_json_field(value, "current_stage", "当前阶段"); - print_json_field(value, "current_message", "当前消息"); - print_daemon_download_progress_json_summary(value.get("download_progress")); - print_json_field(value, "version_state_path", "版本状态路径"); - print_daemon_version_state_json_summary(value.get("version_state")); - print_json_field(value, "resource_output_root", "资源目录"); - print_json_field(value, "state_dir", "状态目录"); - print_json_field(value, "socket_path", "socket"); - print_json_field(value, "log_path", "日志"); - print_json_field(value, "structured_log_path", "结构化日志"); - print_json_field(value, "rotated_structured_log_paths", "轮转日志"); - return Ok(()); - } - if value.get("command").and_then(serde_json::Value::as_str) == Some("logs") { - print_title("后台日志"); - print_json_field(value, "status", "状态"); - print_json_field(value, "message", "消息"); - print_json_field(value, "log_path", "日志"); - print_json_field(value, "bytes", "字节"); - print_json_field(value, "total_lines", "总行数"); - print_json_field(value, "returned_lines", "返回行数"); - if let Some(content) = value.get("content").and_then(serde_json::Value::as_str) { - if !content.is_empty() { - println!(); - println!("{content}"); - } - } - return Ok(()); - } - if value.get("command").is_some() && value.get("status").is_some() { - print_title("后台命令"); - print_json_field(value, "command", "命令"); - print_json_field(value, "status", "状态"); - print_json_field(value, "message", "消息"); - print_json_field(value, "force", "force"); - print_json_field(value, "state_dir", "状态目录"); - print_json_field(value, "socket_path", "socket"); - return Ok(()); - } - println!("{}", serde_json::to_string_pretty(value)?); - Ok(()) -} - -fn print_daemon_download_progress_json_summary(value: Option<&serde_json::Value>) { - let Some(value) = value else { - return; - }; - if value.is_null() { - return; - } - print_field( - "下载进度", - format_daemon_download_progress_json(value) - .unwrap_or_else(|error| format!("无法解析:{error}")), - ); -} - -fn format_daemon_download_progress_json(value: &serde_json::Value) -> Result { - let progress = serde_json::from_value::(value.clone()) - .map_err(|error| error.to_string())?; - Ok(format_daemon_download_progress(&progress)) -} - -fn print_daemon_version_state_json_summary(value: Option<&serde_json::Value>) { - let Some(value) = value else { - return; - }; - if value.is_null() { - return; - } - match serde_json::from_value::(value.clone()) { - Ok(version_state) => print_daemon_version_state_summary(&version_state), - Err(error) => print_field("版本状态", format!("无法解析:{error}")), - } -} - -fn print_daemon_version_state_summary(version_state: &OfficialVersionState) { - print_optional_field( - "当前完成版本", - version_state - .current_completed_version - .as_ref() - .map(|version| version.id.as_str()), - ); - print_optional_field( - "正在拉取版本", - version_state - .in_progress_version - .as_ref() - .map(|version| version.id.as_str()), - ); - print_optional_field( - "上一个可用版本", - version_state - .previous_available_version - .as_ref() - .map(|version| version.id.as_str()), - ); - let historical_failures = visible_historical_failed_versions(version_state); - print_field("历史失败版本数", historical_failures.len()); - if let Some(failed) = historical_failures.last() { - print_field("最近历史失败版本", &failed.version.id); - print_field("最近历史失败时间", failed.failed_unix_seconds); - print_field("最近历史失败原因", &failed.error); - } -} - -fn visible_historical_failed_versions( - version_state: &OfficialVersionState, -) -> Vec<&OfficialFailedVersionRecord> { - let in_progress = version_state.in_progress_version.as_ref(); - version_state - .failed_versions - .iter() - .filter(|failed| { - !in_progress.is_some_and(|version| version_matches_for_status(&failed.version, version)) - }) - .collect() -} - -fn version_matches_for_status(left: &OfficialVersionRecord, right: &OfficialVersionRecord) -> bool { - left.app_version == right.app_version - && left.bundle_version == right.bundle_version - && left.addressables_root == right.addressables_root -} - -fn print_json_field(value: &serde_json::Value, key: &str, label: &str) { - let Some(value) = value.get(key) else { - return; - }; - if value.is_null() { - return; - } - if let Some(value) = value.as_str() { - print_field(label, value); - } else { - print_field(label, value); - } -} - -fn print_title(title: &str) { - println!("{title}"); -} - -fn print_field(label: &str, value: impl std::fmt::Display) { - println!(" {label:<18} {value}"); -} - -fn print_optional_field(label: &str, value: Option) -where - T: std::fmt::Display, -{ - if let Some(value) = value { - print_field(label, value); - } -} - -fn print_path_field(label: &str, value: &Path) { - print_field(label, value.display()); -} - -fn print_optional_path_field(label: &str, value: Option<&PathBuf>) { - if let Some(value) = value { - print_path_field(label, value); - } -} - -fn print_list(label: &str, values: &[String], limit: usize) { - if values.is_empty() { - return; - } - println!(" {label}:"); - for value in values.iter().take(limit) { - println!(" - {value}"); - } - if values.len() > limit { - println!(" ... 还有 {} 项", values.len() - limit); - } -} - -fn format_daemon_download_progress(progress: &DaemonDownloadProgress) -> String { - let status = progress.status.as_deref().unwrap_or("running"); - if 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 { - 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::>() - .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::>(); - 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 HumanReport for CommandReport -where - T: Serialize + HumanReport, -{ - fn print_human(&self) -> anyhow::Result<()> { - print_title(self.message); - print_field("命令", self.command); - print_field("状态", self.status); - self.data.print_human() - } -} - -impl HumanReport for 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::>(); - print_list("轮转日志", &rotated, 5); - Ok(()) - } -} - -impl HumanReport for DaemonStopReport { - fn print_human(&self) -> anyhow::Result<()> { - print_title(self.message); - print_field("状态", self.status); - print_field("已停止", format_bool(self.stopped)); - print_optional_field("PID", self.pid); - print_path_field("状态目录", &self.state_dir); - print_path_field("socket", &self.socket_path); - Ok(()) - } -} - -impl HumanReport for DaemonControlReport { - fn print_human(&self) -> anyhow::Result<()> { - print_title(self.message); - print_field("命令", self.command); - print_field("状态", self.status); - print_field("策略", self.strategy); - print_optional_field("旧 PID", self.previous_pid); - print_field("PID", self.pid); - print_path_field("资源目录", &self.resource_output_root); - print_path_field("状态目录", &self.state_dir); - print_path_field("socket", &self.socket_path); - print_path_field("日志", &self.log_path); - Ok(()) - } -} - -impl HumanReport for VerifyCommandReport { - fn print_human(&self) -> anyhow::Result<()> { - print_title(self.message); - print_field("命令", self.command); - print_field("状态", self.status); - print_field("健康", format_bool(self.healthy)); - print_field("远端状态", &self.remote_update_status); - print_path_field("校验资源目录", &self.verified_resource_root); - print_optional_field("计划 URL 数", self.planned_url_count); - print_field("计划异常数", self.expected_plan_failure_count); - print_field("本地 manifest 项", self.local_manifest_entry_count); - print_field("本地校验通过", self.local_manifest_verified_count); - print_field("本地失败数", self.local_manifest_failure_count); - print_field("官方 hash 对", self.official_hash_pair_count); - print_field("官方 hash 通过", self.official_hash_verified_count); - print_verification_summary(&self.verification_summary); - if !self.official_hash_errors.is_empty() { - print_list("官方 hash 错误", &self.official_hash_errors, 8); - } - if !self.failures.is_empty() { - println!(" 失败项:"); - for item in self.failures.iter().take(12) { - println!(" - {} -> {}", item.status, item.destination.display()); - } - if self.failures.len() > 12 { - println!(" ... 还有 {} 项", self.failures.len() - 12); - } - } - Ok(()) - } -} - -impl HumanReport for LogsReport { - fn print_human(&self) -> anyhow::Result<()> { - print_title(self.message); - print_field("状态", self.status); - print_path_field("日志", &self.log_path); - print_field("存在", format_bool(self.exists)); - print_field("为空", format_bool(self.empty)); - print_field("字节", self.bytes); - print_field("总行数", self.total_lines); - print_field("返回行数", self.returned_lines); - if !self.content.is_empty() { - println!(); - println!("{}", self.content); - } - Ok(()) - } -} - -impl HumanReport for DoctorReport { - fn print_human(&self) -> anyhow::Result<()> { - print_title(self.message); - print_field("命令", self.command); - print_field("状态", self.status); - print_field("健康", format_bool(self.healthy)); - println!(" 检查:"); - for check in &self.checks { - println!( - " [{}] {} - {}", - if check.ok { "OK" } else { "FAIL" }, - check.name, - check.message - ); - } - Ok(()) - } -} - -impl HumanReport for CleanStableReport { - fn print_human(&self) -> anyhow::Result<()> { - print_title(self.message); - print_field("命令", self.command); - print_field("状态", self.status); - print_path_field("资源目录", &self.output_root); - print_path_field("状态目录", &self.state_dir); - if !self.removed_paths.is_empty() { - println!(" 已清理:"); - for path in &self.removed_paths { - println!(" - {}", path.display()); - } - } - if !self.skipped_paths.is_empty() { - println!(" 已跳过:"); - for path in &self.skipped_paths { - println!(" - {}", path.display()); - } - } - Ok(()) - } -} - -impl HumanReport for DaemonRpcAck { - fn print_human(&self) -> anyhow::Result<()> { - print_title(self.message); - print_field("命令", self.command); - print_field("状态", self.status); - print_optional_field("force", self.force.map(format_bool)); - print_path_field("状态目录", &self.state_dir); - print_path_field("socket", &self.socket_path); - Ok(()) - } -} - -#[derive(Debug, Serialize)] -struct VerificationItemReport { - url: String, - destination: PathBuf, - status: String, - expected_bytes: Option, - actual_bytes: Option, - expected_blake3: Option, - actual_blake3: Option, - zip_error: Option, -} - -#[derive(Debug, Serialize)] -struct VerifyCommandReport { - command: &'static str, - status: &'static str, - message: &'static str, - healthy: bool, - remote_update_status: String, - verified_resource_root: PathBuf, - planned_url_count: Option, - expected_plan_failure_count: usize, - local_manifest_entry_count: usize, - local_manifest_verified_count: usize, - local_manifest_failure_count: usize, - official_hash_pair_count: usize, - official_hash_verified_count: usize, - verification_summary: OfficialVerificationSummary, - official_hash_errors: Vec, - failures: Vec, -} - -fn run_verify_command(options: &CliOptions) -> anyhow::Result { - let mut config = options.config.clone(); - config.dry_run = true; - config.plan = true; - config.audit_local = true; - config.repair = false; - config.force = false; - - let mut logger = ProgressLogger::new(options.progress); - let update_report = - OfficialUpdateService::new().run_with_progress(&config, |event| logger.log(event))?; - let verified_resource_root = active_official_resource_root(&config.output_root)?; - let verification = bat_infrastructure::OfficialResourcePullService::with_curl_command( - &verified_resource_root, - &config.curl_command, - ) - .with_proxy_config(config.curl_proxy.clone()) - .verify_local_download_manifest() - .map_err(anyhow::Error::msg)?; - let failures = verification - .items - .iter() - .filter(|item| !item.status.is_verified()) - .map(|item| VerificationItemReport { - url: item.url.clone(), - destination: item.destination.clone(), - status: item.status.as_str().to_string(), - expected_bytes: item.expected_bytes, - actual_bytes: item.actual_bytes, - expected_blake3: item.expected_blake3.clone(), - actual_blake3: item.actual_blake3.clone(), - zip_error: item.zip_error.clone(), - }) - .collect::>(); - let healthy = update_report.update_status == OfficialUpdateStatus::UpToDate - && update_report.local_manifest_repair_needed_count == 0 - && verification.is_clean(); - let verification_summary = OfficialVerificationSummary::new( - verification.manifest_blake3_verified_count(), - verification.failure_count(), - verification.official_hash_verified_count, - verification.zip_structure_verified_count(), - ); - let report = VerifyCommandReport { - command: "verify", - status: if healthy { "verified" } else { "failed" }, - message: if healthy { - "所有当前计划资源和本地 manifest 均通过验证" - } else { - "发现资源或官方 seed hash 校验异常" - }, - healthy, - remote_update_status: update_report.update_status.as_str().to_string(), - verified_resource_root, - planned_url_count: update_report.download_url_count, - expected_plan_failure_count: update_report.local_manifest_repair_needed_count, - local_manifest_entry_count: verification.items.len(), - local_manifest_verified_count: verification.verified_count(), - local_manifest_failure_count: verification.failure_count(), - official_hash_pair_count: verification.official_hash_pair_count, - official_hash_verified_count: verification.official_hash_verified_count, - verification_summary, - official_hash_errors: verification.official_hash_errors, - failures, - }; - print_report(options.output_format, &report)?; - Ok(healthy) -} - -#[derive(Debug, Serialize)] -struct LogsReport { - command: &'static str, - status: &'static str, - message: &'static str, - log_path: PathBuf, - exists: bool, - empty: bool, - bytes: usize, - total_lines: usize, - returned_lines: usize, - content: String, -} - -fn run_logs_command(options: &CliOptions) -> anyhow::Result<()> { - if daemon_rpc_available(&options.state_dir) { - let report = daemon_rpc_call( - &options.state_dir, - RPC_METHOD_LOGS, - Some(serde_json::json!({ "tail": options.tail_lines })), - )?; - print_json_value(options.output_format, &report)?; - return Ok(()); - } - - let report = build_logs_report(&options.state_dir, options.tail_lines)?; - print_report(options.output_format, &report)?; - Ok(()) -} - -fn build_logs_report(state_dir: &Path, tail_lines: usize) -> anyhow::Result { - let status_file = read_daemon_status_file(&daemon_status_path(state_dir))?; - let log_path = status_file - .as_ref() - .map(|status| status.log_path.clone()) - .unwrap_or_else(|| daemon_log_path(state_dir)); - let bytes = read_file_no_symlink(&log_path, "后台日志").map_err(anyhow::Error::msg)?; - let exists = bytes.is_some(); - let bytes = bytes.unwrap_or_default(); - let raw = String::from_utf8_lossy(&bytes); - let lines = raw.lines().collect::>(); - let start = lines.len().saturating_sub(tail_lines); - let content = lines[start..].join("\n"); - Ok(LogsReport { - command: "logs", - status: if bytes.is_empty() { "empty" } else { "ok" }, - message: if bytes.is_empty() { - "后台日志为空或尚未创建" - } else { - "已读取后台日志" - }, - log_path, - exists, - empty: bytes.is_empty(), - bytes: bytes.len(), - total_lines: lines.len(), - returned_lines: lines.len().saturating_sub(start), - content, - }) -} - -#[derive(Debug, Serialize)] -struct DoctorCheck { - name: &'static str, - ok: bool, - message: String, -} - -#[derive(Debug, Serialize)] -struct DoctorReport { - command: &'static str, - status: &'static str, - message: &'static str, - healthy: bool, - checks: Vec, -} - -fn build_doctor_report( - state_dir: &Path, - config: &OfficialUpdateConfig, -) -> anyhow::Result { - 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 { - 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, - skipped_paths: Vec, -} - -fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> { - validate_output_root(&options.config.output_root).map_err(anyhow::Error::msg)?; - validate_runtime_state_dir(&options.state_dir).map_err(anyhow::Error::msg)?; - let status = build_daemon_status_report(&options.state_dir)?; - if status.running || status.rpc_available { - return Err(anyhow::anyhow!( - "后台进程正在运行,不能执行 clean-stable;请先执行 stop" - )); - } - - let mut removed_paths = Vec::new(); - let mut skipped_paths = Vec::new(); - for root in [&options.config.output_root, &options.state_dir] { - collect_transient_files(root, &mut removed_paths)?; - } - - let pid_path = daemon_pid_path(&options.state_dir); - match classify_pid_lock_file(&pid_path)? { - PidLockState::Missing => {} - PidLockState::Active(_) => skipped_paths.push(pid_path), - PidLockState::StalePid(_) | PidLockState::Corrupt => { - fs::remove_file(&pid_path)?; - removed_paths.push(pid_path); - } - } - - let socket_path = daemon_socket_path(&options.state_dir); - if daemon_socket_path_exists(&socket_path)? { - if daemon_rpc_available(&options.state_dir) { - skipped_paths.push(socket_path); - } else { - fs::remove_file(&socket_path)?; - removed_paths.push(socket_path); - } - } - - let lock_path = options.config.lock_path(); - match classify_pid_lock_file(&lock_path)? { - PidLockState::Missing => {} - PidLockState::Active(_) => skipped_paths.push(lock_path), - PidLockState::StalePid(_) | PidLockState::Corrupt => { - fs::remove_file(&lock_path)?; - removed_paths.push(lock_path); - } - } - - let control_lock_path = daemon_control_lock_path(&options.state_dir); - match classify_pid_lock_file(&control_lock_path)? { - PidLockState::Missing => {} - PidLockState::Active(pid) => { - return Err(anyhow::anyhow!( - "后台控制命令已被锁定 (locked):{};owner_pid={pid} 仍在运行", - control_lock_path.display() - )); - } - PidLockState::StalePid(_) | PidLockState::Corrupt => { - fs::remove_file(&control_lock_path)?; - removed_paths.push(control_lock_path); - } - } - - // 后台已停止,清除残留的代理凭据文件,不把凭据留在磁盘上。 - let proxy_secret_path = daemon_proxy_secret_path(&options.state_dir); - if proxy_secret_path.exists() { - fs::remove_file(&proxy_secret_path)?; - removed_paths.push(proxy_secret_path); - } - - // 后台已停止,清理未被版本状态引用的孤儿 staging 目录。 - if let Some(state) = read_version_state(&options.config.version_state_path())? { - removed_paths.extend(gc_orphan_staging(&options.config.output_root, &state)?); - } - - let report = CleanStableReport { - command: "clean-stable", - status: if skipped_paths.is_empty() { - "cleaned" - } else { - "cleaned_with_skips" - }, - message: "已清理断点临时文件、临时状态文件和失效锁/PID;不会删除正式资源", - output_root: options.config.output_root.clone(), - state_dir: options.state_dir.clone(), - removed_paths, - skipped_paths, - }; - print_report(options.output_format, &report)?; - Ok(()) -} - -fn collect_transient_files(root: &Path, removed_paths: &mut Vec) -> anyhow::Result<()> { - if !root.exists() { - return Ok(()); - } - let metadata = fs::symlink_metadata(root)?; - if !metadata.is_dir() { - return Ok(()); - } - - for entry in fs::read_dir(root)? { - let entry = entry?; - let path = entry.path(); - let metadata = fs::symlink_metadata(&path)?; - if metadata.is_dir() { - collect_transient_files(&path, removed_paths)?; - continue; - } - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(""); - if file_name.ends_with(".part") || file_name.ends_with(".tmp") { - fs::remove_file(&path)?; - removed_paths.push(path); - } - } - Ok(()) -} - -fn read_daemon_status_file(path: &Path) -> anyhow::Result> { - let Some(bytes) = read_file_no_symlink(path, "后台状态文件").map_err(anyhow::Error::msg)? - else { - return Ok(None); - }; - Ok(Some(serde_json::from_slice(&bytes)?)) -} - -/// 串行化 `bat-status.json` 的读-改-写。 -/// -/// watch 循环线程(`update_daemon_status`/`update_daemon_progress`)与 RPC 处理线程 -/// (`update_daemon_state_only`)都会更新状态文件;单次写虽原子,但“读→改字段→写回” -/// 整体非原子,无此锁会因 last-writer-wins 丢失并发线程的字段更新。 -static DAEMON_STATUS_FILE_LOCK: Mutex<()> = Mutex::new(()); - -fn lock_daemon_status_file() -> std::sync::MutexGuard<'static, ()> { - DAEMON_STATUS_FILE_LOCK - .lock() - .unwrap_or_else(|poison| poison.into_inner()) -} - -fn write_daemon_status_file(path: &Path, status: &DaemonStatusFile) -> anyhow::Result<()> { - write_file_atomic( - path, - &serde_json::to_vec_pretty(status)?, - PRIVATE_FILE_MODE, - "后台状态文件", - ) - .map_err(anyhow::Error::msg)?; - Ok(()) -} - -fn update_daemon_status(state_dir: &Path, update: DaemonStatusUpdate<'_>) -> anyhow::Result<()> { - let _guard = lock_daemon_status_file(); - let status_path = daemon_status_path(state_dir); - let mut status = read_daemon_status_file(&status_path)?.unwrap_or_else(|| DaemonStatusFile { - version: DAEMON_STATUS_VERSION, - pid: std::process::id(), - state: "started".to_string(), - resource_output_root: OfficialUpdateConfig::default().output_root, - 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> { - let Some(bytes) = read_file_no_symlink(path, "后台 PID 文件").map_err(anyhow::Error::msg)? - else { - return Ok(None); - }; - let contents = String::from_utf8(bytes)?; - Ok(contents.trim().parse::().ok().filter(|pid| *pid > 0)) -} - -fn daemon_pid_path(state_dir: &Path) -> PathBuf { - state_dir.join(DAEMON_PID_FILE) -} - -fn daemon_status_path(state_dir: &Path) -> PathBuf { - state_dir.join(DAEMON_STATUS_FILE) -} - -fn daemon_log_path(state_dir: &Path) -> PathBuf { - state_dir.join(DAEMON_LOG_FILE) -} - -fn daemon_structured_log_path(state_dir: &Path) -> PathBuf { - state_dir.join(DAEMON_STRUCTURED_LOG_FILE) -} - -fn rotated_structured_log_path(path: &Path, index: usize) -> PathBuf { - let file_name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(DAEMON_STRUCTURED_LOG_FILE); - path.with_file_name(format!("{file_name}.{index}")) -} - -fn rotated_structured_log_paths(path: &Path) -> Vec { - (1..=STRUCTURED_LOG_ROTATE_KEEP) - .map(|index| rotated_structured_log_path(path, index)) - .filter(|path| path_exists_no_follow(path).unwrap_or(false)) - .collect() -} - -fn daemon_socket_path(state_dir: &Path) -> PathBuf { - state_dir.join(DAEMON_SOCKET_FILE) -} - -fn daemon_control_lock_path(state_dir: &Path) -> PathBuf { - state_dir.join(DAEMON_CONTROL_LOCK_FILE) -} - -fn daemon_proxy_secret_path(state_dir: &Path) -> PathBuf { - state_dir.join(DAEMON_PROXY_SECRET_FILE) -} - -/// 返回 URL 型代理的凭据字符串;auto/disabled 模式返回 None。 -fn curl_proxy_url(config: &CurlProxyConfig) -> Option { - match config.mode() { - CurlProxyMode::Url(url) => Some(url.clone()), - CurlProxyMode::Auto | CurlProxyMode::Disabled => None, - } -} - -/// 将代理凭据写入专用 0600 文件,供 restart/reload 复用;该文件永不进入状态输出。 -fn write_daemon_proxy_secret(state_dir: &Path, url: &str) -> anyhow::Result<()> { - write_file_atomic( - &daemon_proxy_secret_path(state_dir), - url.as_bytes(), - PRIVATE_FILE_MODE, - "代理凭据文件", - ) - .map_err(anyhow::Error::msg) -} - -/// 删除代理凭据文件(best-effort);无代理或拆除后台时调用。 -fn clear_daemon_proxy_secret(state_dir: &Path) { - let _ = fs::remove_file(daemon_proxy_secret_path(state_dir)); -} - -/// 读取代理凭据文件;缺失或为空返回 None。 -fn read_daemon_proxy_secret(state_dir: &Path) -> anyhow::Result> { - let Some(bytes) = read_file_no_symlink(&daemon_proxy_secret_path(state_dir), "代理凭据文件") - .map_err(anyhow::Error::msg)? - else { - return Ok(None); - }; - let url = - String::from_utf8(bytes).map_err(|_| anyhow::anyhow!("代理凭据文件不是有效 UTF-8"))?; - let trimmed = url.trim(); - if trimmed.is_empty() { - Ok(None) - } else { - Ok(Some(trimmed.to_string())) - } -} - -fn active_official_resource_root(output_root: &Path) -> anyhow::Result { - let current_path = output_root.join(OFFICIAL_CURRENT_LINK); - let metadata = match fs::symlink_metadata(¤t_path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(output_root.to_path_buf()); - } - Err(error) => return Err(error.into()), - }; - if !metadata.file_type().is_symlink() { - return Err(anyhow::anyhow!( - "current 已存在但不是 symlink:{}", - current_path.display() - )); - } - let target = fs::read_link(¤t_path)?; - let target = if target.is_absolute() { - target - } else { - output_root.join(target) - }; - lexical_absolute(&target).map_err(anyhow::Error::msg) -} - -fn daemon_child_args(options: &CliOptions) -> Vec { - let mut args = Vec::new(); - let config = &options.config; - if config.auto_discover { - args.push("--auto-discover".to_string()); - } - if let Some(source) = config.server_info_source.as_ref() { - match source { - OfficialServerInfoSource::LocalPath(path) => { - args.push("--server-info-path".to_string()); - args.push(path.to_string_lossy().to_string()); - } - OfficialServerInfoSource::OfficialFile(name) => { - args.push("--server-info-file".to_string()); - args.push(name.clone()); - } - OfficialServerInfoSource::OfficialUrl(url) => { - args.push("--server-info-url".to_string()); - args.push(url.clone()); - } - } - } - if let Some(connection_group) = config.connection_group.as_ref() { - args.push("--connection-group".to_string()); - args.push(connection_group.clone()); - } - if let Some(app_version) = config.app_version.as_ref() { - args.push("--app-version".to_string()); - args.push(app_version.clone()); - } - args.push("--launcher-version".to_string()); - args.push(config.launcher_version.clone()); - if let Some(platforms) = config.platforms.as_ref() { - args.push("--platforms".to_string()); - args.push( - platforms - .iter() - .map(|platform| match platform { - PatchPlatform::Windows => "Windows", - PatchPlatform::Android => "Android", - }) - .collect::>() - .join(","), - ); - } - args.push("--output".to_string()); - args.push(config.output_root.to_string_lossy().to_string()); - 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//cmdline)与状态文件 command 字段。 - args.push(PROXY_FROM_ENV_FLAG.to_string()); - } - } - args.push("--unzip".to_string()); - args.push(config.unzip_command.to_string_lossy().to_string()); - if config.force { - args.push("--force".to_string()); - } - if config.audit_local { - args.push("--audit-local".to_string()); - } else { - args.push("--no-audit-local".to_string()); - } - if config.repair { - args.push("--repair".to_string()); - } else { - args.push("--no-repair".to_string()); - } - args.push("--state-dir".to_string()); - args.push(options.state_dir.to_string_lossy().to_string()); - args.push("--daemon-child".to_string()); - args.push("--watch".to_string()); - args.push("--interval".to_string()); - args.push(format_duration_arg(options.interval)); - args.push("--error-retry".to_string()); - args.push(format_duration_arg(options.error_retry_interval)); - if options.quiet_up_to_date { - args.push("--quiet-up-to-date".to_string()); - } else { - args.push("--no-quiet-up-to-date".to_string()); - } - if options.progress { - args.push("--progress".to_string()); - } else { - args.push("--no-progress".to_string()); - } - if options.output_format == OutputFormat::Json { - args.push("--json".to_string()); - } - if options.banner { - args.push("--banner".to_string()); - } else { - args.push("--no-banner".to_string()); - } - args -} - -fn format_duration_arg(duration: Duration) -> String { - let millis = duration.as_millis(); - if millis.is_multiple_of(3_600_000) { - format!("{}h", millis / 3_600_000) - } else if millis.is_multiple_of(60_000) { - format!("{}m", millis / 60_000) - } else if millis.is_multiple_of(1_000) { - format!("{}s", millis / 1_000) - } else { - format!("{millis}ms") - } -} - -fn unix_seconds_now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() -} - -fn unix_seconds_after(duration: Duration) -> u64 { - unix_seconds_now().saturating_add(duration.as_secs()) -} - -fn system_time_to_unix_seconds(time: SystemTime) -> Option { - time.duration_since(UNIX_EPOCH) - .ok() - .map(|duration| duration.as_secs()) -} - -#[cfg(unix)] -fn configure_daemon_command(command: &mut Command) { - use std::os::unix::process::CommandExt; - - unsafe { - command.pre_exec(|| { - if libc::setsid() < 0 { - Err(std::io::Error::last_os_error()) - } else { - Ok(()) - } - }); - } -} - -#[cfg(not(unix))] -fn configure_daemon_command(_command: &mut Command) {} - -#[cfg(unix)] -fn process_exists(pid: u32) -> bool { - let Ok(pid) = libc::pid_t::try_from(pid) else { - return false; - }; - if pid <= 0 { - return false; - } - unsafe { - libc::kill(pid, 0) == 0 - || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) - } -} - -// 非 Unix 平台无法用 kill(pid, 0) 探测进程存活;生产链路只支持 Linux。 -// 这里保守返回 true(假定进程仍存活),避免误把可能仍在运行的 daemon 的 -// PID/锁文件当作 stale 而回收——宁可要求手动清理,也不冒重复启动的风险。 -#[cfg(not(unix))] -fn process_exists(_pid: u32) -> bool { - true -} - -#[cfg(unix)] -fn terminate_process(pid: u32) -> anyhow::Result<()> { - let pid = libc::pid_t::try_from(pid)?; - if pid <= 0 { - return Err(anyhow::anyhow!("无效的后台进程 pid")); - } - let rc = unsafe { libc::kill(pid, libc::SIGTERM) }; - if rc == 0 { - Ok(()) - } else { - Err(std::io::Error::last_os_error().into()) - } -} - -#[cfg(not(unix))] -fn terminate_process(_pid: u32) -> anyhow::Result<()> { - Err(anyhow::anyhow!("stop 目前只支持 Unix/Linux 平台")) -} - -fn wait_for_process_exit(pid: u32, timeout: Duration) -> bool { - let started_at = Instant::now(); - while started_at.elapsed() < timeout { - if !process_exists(pid) { - return true; - } - thread::sleep(Duration::from_millis(100)); - } - !process_exists(pid) -} - -fn wait_for_rpc_stop_or_terminate(pid: u32, timeout: Duration) -> anyhow::Result { - if wait_for_process_exit(pid, timeout) { - return Ok(false); - } - terminate_process(pid)?; - if !wait_for_process_exit(pid, Duration::from_secs(5)) { - return Err(anyhow::anyhow!("后台进程 pid={pid} 在 SIGTERM 后仍未停止")); - } - Ok(true) -} - -fn next_forced_refresh_at_or_after(now: SystemTime) -> SystemTime { - forced_refresh_time(now, ForcedRefreshBoundary::AtOrAfter) -} - -fn next_forced_refresh_after(now: SystemTime) -> SystemTime { - forced_refresh_time(now, ForcedRefreshBoundary::After) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ForcedRefreshBoundary { - AtOrAfter, - After, -} - -fn forced_refresh_time(now: SystemTime, boundary: ForcedRefreshBoundary) -> SystemTime { - let local_seconds = beijing_local_seconds_since_epoch(now); - let local_day = local_seconds / SECONDS_PER_DAY; - let local_second_of_day = local_seconds % SECONDS_PER_DAY; - - for scheduled_second in DAILY_FORCED_REFRESH_LOCAL_SECONDS { - let matches_boundary = match boundary { - ForcedRefreshBoundary::AtOrAfter => scheduled_second >= local_second_of_day, - ForcedRefreshBoundary::After => scheduled_second > local_second_of_day, - }; - if matches_boundary { - return system_time_from_beijing_local_seconds( - local_day - .saturating_mul(SECONDS_PER_DAY) - .saturating_add(scheduled_second), - ); - } - } - - system_time_from_beijing_local_seconds( - local_day - .saturating_add(1) - .saturating_mul(SECONDS_PER_DAY) - .saturating_add(DAILY_FORCED_REFRESH_LOCAL_SECONDS[0]), - ) -} - -fn beijing_local_seconds_since_epoch(time: SystemTime) -> u64 { - time.duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - .saturating_add(BEIJING_UTC_OFFSET_SECONDS) -} - -fn system_time_from_beijing_local_seconds(local_seconds: u64) -> SystemTime { - UNIX_EPOCH + Duration::from_secs(local_seconds.saturating_sub(BEIJING_UTC_OFFSET_SECONDS)) -} - -fn duration_until(deadline: SystemTime, now: SystemTime) -> Duration { - deadline.duration_since(now).unwrap_or_default() -} - -#[derive(Debug, Clone)] -struct ProgressLogger { - enabled: bool, - started_at: Instant, - structured: Option, -} - -impl ProgressLogger { - fn new(enabled: bool) -> Self { - Self { - enabled, - started_at: Instant::now(), - structured: None, - } - } - - fn attach_structured_log(&mut self, path: PathBuf) { - self.structured = Some(RotatingStructuredLogger::new( - path, - STRUCTURED_LOG_MAX_BYTES, - STRUCTURED_LOG_ROTATE_KEEP, - )); - } - - fn log(&mut self, event: OfficialUpdateProgress) { - self.log_event(&event); - } - - fn log_text(&mut self, stage: &str, message: impl AsRef) { - self.log_text_inner(stage, message.as_ref()); - } - - fn log_event(&mut self, event: &OfficialUpdateProgress) { - if self.enabled { - eprintln!( - "[+{} 信息] [{}] {}", - format_duration(self.started_at.elapsed()), - localized_stage(event.stage), - event.message - ); - } - if let Some(structured) = self.structured.as_mut() { - let _ = structured.write_event(self.started_at.elapsed(), event); - } - } - - fn log_text_inner(&mut self, stage: &str, message: &str) { - if !self.enabled { - if let Some(structured) = self.structured.as_mut() { - let event = OfficialUpdateProgress::new(stage_to_static(stage), message); - let _ = structured.write_event(self.started_at.elapsed(), &event); - } - return; - } - eprintln!( - "[+{} 信息] [{}] {}", - format_duration(self.started_at.elapsed()), - localized_stage(stage), - message - ); - if let Some(structured) = self.structured.as_mut() { - let event = OfficialUpdateProgress::new(stage_to_static(stage), message); - let _ = structured.write_event(self.started_at.elapsed(), &event); - } - } -} - -#[derive(Debug, Clone)] -struct RotatingStructuredLogger { - path: PathBuf, - max_bytes: u64, - keep: usize, -} - -impl RotatingStructuredLogger { - fn new(path: PathBuf, max_bytes: u64, keep: usize) -> Self { - Self { - path, - max_bytes, - keep, - } - } - - fn write_event( - &mut self, - elapsed: Duration, - event: &OfficialUpdateProgress, - ) -> anyhow::Result<()> { - let payload = serde_json::json!({ - "timestamp_unix_seconds": unix_seconds_now(), - "elapsed_ms": elapsed.as_millis(), - "level": "info", - "stage": event.stage, - "stage_label": localized_stage(event.stage), - "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 目录;未设置时默认 /.cas -BAT_IMPORT_CAS_ROOT= -# 官方资源 SQLite 索引;未设置时默认 /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-tasks.json; -# Redis 任务后端落地后以下配置才会生效。 -#BAT_REDIS_URL=redis://127.0.0.1:6379 -#BAT_REDIS_PASSWORD= -"#; - -/// `.env` 引导:首次启动时在二进制所在目录释放配置模板,之后每次启动把其中的 -/// 键加载为进程环境变量(不覆盖已存在的环境变量,保持"环境变量 > .env"优先级)。 -/// -/// 任何失败只在 stderr 警告、不中断启动——`.env` 是便利层,不是启动硬依赖。 -fn bootstrap_env_file() { - if env::var(SKIP_ENV_FILE_VAR).map(|value| value == "1") == Ok(true) { - return; - } - let Ok(exe_path) = env::current_exe() else { - return; - }; - let Some(exe_dir) = exe_path.parent() else { - return; - }; - let path = exe_dir.join(ENV_FILE_NAME); - if !path.exists() { - match write_env_template(&path) { - Ok(()) => eprintln!( - "已生成配置模板 {}(编辑其中的 BAT_* 配置后,直接运行 `bat` 即可按 .env 启动)", - path.display() - ), - Err(error) => { - eprintln!("警告:生成 .env 配置模板失败 {}:{error}", path.display()); - return; - } - } - } - match fs::read_to_string(&path) { - Ok(content) => apply_env_file(&content), - Err(error) => eprintln!("警告:读取 .env 失败 {}:{error}", path.display()), - } -} - -/// 以 `create_new` 原子创建模板文件,避免并发启动时互相覆盖;unix 下限制 `0600` -/// 权限(`.env` 可能保存代理凭据等敏感配置)。 -fn write_env_template(path: &Path) -> std::io::Result<()> { - let mut open_options = OpenOptions::new(); - open_options.write(true).create_new(true); - #[cfg(unix)] - open_options.mode(PRIVATE_FILE_MODE); - let mut file = open_options.open(path)?; - file.write_all(ENV_TEMPLATE.as_bytes()) -} - -/// 解析 `.env` 内容,把进程环境里尚不存在的键设为环境变量。 -fn apply_env_file(content: &str) { - for (line_number, raw_line) in content.lines().enumerate() { - let line = raw_line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let Some((key, value)) = parse_env_line(line) else { - eprintln!( - "警告:.env 第 {} 行无法解析,已忽略:{raw_line}", - line_number + 1 - ); - continue; - }; - if env::var_os(&key).is_none() { - env::set_var(&key, value); - } - } -} - -/// 解析单行 `KEY=VALUE`。key 须为 `[A-Za-z_][A-Za-z0-9_]*`;值两侧的成对 -/// 单/双引号会剥除。不支持 `export` 前缀和多行值。 -fn parse_env_line(line: &str) -> Option<(String, String)> { - let (key, value) = line.split_once('=')?; - let key = key.trim(); - let valid_key = !key.is_empty() - && key.chars().enumerate().all(|(index, character)| { - character == '_' - || character.is_ascii_alphabetic() - || (index > 0 && character.is_ascii_digit()) - }); - if !valid_key { - return None; - } - let mut value = value.trim(); - if value.len() >= 2 { - let bytes = value.as_bytes(); - let quoted = (bytes[0] == b'"' && bytes[value.len() - 1] == b'"') - || (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\''); - if quoted { - value = &value[1..value.len() - 1]; - } - } - Some((key.to_string(), value.to_string())) -} - -/// `.env`/环境变量提供的运行模式开关(延迟到命令确定后应用;值型配置 -/// 由 [`apply_bat_env_overrides`] 直接写入 options)。 -struct EnvModeOverrides { - watch: bool, - daemon: bool, -} - -/// 把 `BAT_*` 环境变量作为配置默认值写入 options。 -/// -/// 不标记任何 `*_explicit`(命令行参数在其后解析、总是覆盖);非法值报错 -/// 而非静默忽略,保证配置问题可诊断。 -fn apply_bat_env_overrides( - options: &mut CliOptions, - env_lookup: &impl Fn(&str) -> Option, -) -> anyhow::Result { - fn parse_env_bool(key: &str, value: &str) -> anyhow::Result { - match value.to_ascii_lowercase().as_str() { - "1" | "true" | "yes" | "on" => Ok(true), - "0" | "false" | "no" | "off" => Ok(false), - other => Err(anyhow::anyhow!( - "环境变量 {key} 的布尔值无效:{other}(支持 1/0/true/false/yes/no/on/off)" - )), - } - } - fn parse_env_seconds(key: &str, value: &str) -> anyhow::Result { - let seconds = value - .parse::() - .map_err(|error| anyhow::anyhow!("环境变量 {key} 的秒数无效:{error}"))?; - Ok(Duration::from_secs(seconds)) - } - // 空值视为未设置:模板里保留 `BAT_XXX=` 形式的空行不产生副作用。 - let value = |key: &str| { - env_lookup(key) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - }; - - if let Some(v) = value("BAT_OUTPUT") { - options.config.output_root = PathBuf::from(v); - } - if let Some(v) = value("BAT_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 { - parse_args_with_env(env::args(), |key| env::var(key).ok()) -} - -/// 测试入口:不读环境变量,解析结果只由参数决定。 -#[cfg(test)] -fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result { - parse_args_with_env(raw_args, |_| None) -} - -fn parse_args_with_env( - raw_args: impl IntoIterator, - env_lookup: impl Fn(&str) -> Option, -) -> anyhow::Result { - let mut args = raw_args.into_iter(); - let binary = args.next().unwrap_or_else(|| "bat".to_string()); - let mut options = CliOptions::default(); - // `BAT_*` 环境变量(含 .env 加载的)先作为默认值写入,不标记 explicit; - // 命令行参数随后解析,逐字段覆盖。工具/代理的"非默认"判断以本基线为准, - // 保证 status/stop/logs 在 .env 存在时不误判为显式传了同步参数。 - let env_modes = apply_bat_env_overrides(&mut options, &env_lookup)?; - options.env_baseline_config = options.config.clone(); - let mut mode_flag_from_cli = false; - - while let Some(flag) = args.next() { - match flag.as_str() { - "status" => { - ensure_command_not_set(options.command, "status")?; - options.command = CliCommand::Status; - } - "stop" => { - ensure_command_not_set(options.command, "stop")?; - options.command = CliCommand::Stop; - } - "restart" => { - ensure_command_not_set(options.command, "restart")?; - options.command = CliCommand::Restart; - } - "reload" => { - ensure_command_not_set(options.command, "reload")?; - options.command = CliCommand::Reload; - } - "refresh" => { - ensure_command_not_set(options.command, "refresh")?; - options.command = CliCommand::Refresh; - } - "verify" => { - ensure_command_not_set(options.command, "verify")?; - options.command = CliCommand::Verify; - } - "repair" => { - ensure_command_not_set(options.command, "repair")?; - options.command = CliCommand::Repair; - } - "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::() - .map_err(|error| anyhow::anyhow!("{flag} 的秒数无效:{error}"))?; - options.interval = Duration::from_secs(seconds); - options.sync_option_explicit = true; - } - "--error-retry" => { - options.error_retry_interval = - parse_duration(&next_option_value(&mut args, &flag)?)?; - options.sync_option_explicit = true; - } - "--error-retry-seconds" => { - let seconds = next_option_value(&mut args, &flag)? - .parse::() - .map_err(|error| anyhow::anyhow!("{flag} 的秒数无效:{error}"))?; - options.error_retry_interval = Duration::from_secs(seconds); - options.sync_option_explicit = true; - } - "--quiet-up-to-date" => { - options.quiet_up_to_date = true; - options.quiet_up_to_date_explicit = true; - options.sync_option_explicit = true; - } - "--no-quiet-up-to-date" => { - options.quiet_up_to_date = false; - options.quiet_up_to_date_explicit = true; - options.sync_option_explicit = true; - } - "--progress" => { - options.progress = true; - options.banner = true; - } - "--no-progress" => { - options.progress = false; - options.banner = false; - } - "--json" => { - options.output_format = OutputFormat::Json; - } - "--human" => { - options.output_format = OutputFormat::Human; - } - "--banner" => { - options.banner = true; - } - "--no-banner" => { - options.banner = false; - } - "--tail" => { - options.tail_lines = next_option_value(&mut args, &flag)? - .parse::() - .map_err(|error| anyhow::anyhow!("{flag} 的行数无效:{error}"))?; - if options.tail_lines == 0 { - return Err(anyhow::anyhow!("--tail 必须大于 0")); - } - } - "--offset" => { - options.query_offset = next_option_value(&mut args, &flag)? - .parse::() - .map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?; - options.query_option_explicit = true; - } - "--limit" => { - options.query_limit = next_option_value(&mut args, &flag)? - .parse::() - .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::() - .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::() - .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::() - .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 优先于 watch(daemon 自带 watch)。 - if matches!(options.command, CliCommand::Run) && !mode_flag_from_cli && !options.config.dry_run - { - if env_modes.daemon { - options.daemon = true; - } else if env_modes.watch { - options.watch = true; - } - } - - 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 { - let parsed = value - .parse::() - .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, - flag: &str, -) -> anyhow::Result { - args.next() - .ok_or_else(|| anyhow::anyhow!("{flag} 缺少参数值")) -} - -/// curl 支持的代理 scheme。 -const SUPPORTED_PROXY_SCHEMES: [&str; 6] = - ["http", "https", "socks4", "socks4a", "socks5", "socks5h"]; - -/// 校验显式代理 URL 的 scheme,尽早拒绝拼错的 scheme(如 `htp://`)。 -/// -/// 无 `://` 时 curl 默认按 http 处理 `host:port`,此处只要求非空;带 `://` 时 -/// scheme 必须是 curl 支持的代理协议,且代理主机部分不能为空。 -fn validate_proxy_url(url: &str) -> anyhow::Result<()> { - if let Some((scheme, rest)) = url.split_once("://") { - let scheme_lower = scheme.to_ascii_lowercase(); - if !SUPPORTED_PROXY_SCHEMES.contains(&scheme_lower.as_str()) { - return Err(anyhow::anyhow!( - "--proxy 使用了不支持的 scheme:{scheme}(支持 {})", - SUPPORTED_PROXY_SCHEMES.join("/") - )); - } - if rest.is_empty() { - return Err(anyhow::anyhow!("--proxy 缺少代理主机:{url}")); - } - } - Ok(()) -} - -fn parse_proxy_config(value: &str) -> anyhow::Result { - let normalized = value.trim(); - if normalized.is_empty() { - return Err(anyhow::anyhow!("--proxy 不能为空")); - } - - match normalized.to_ascii_lowercase().as_str() { - "auto" | "env" => Ok(CurlProxyConfig::auto()), - "none" | "direct" | "off" | "disabled" => Ok(CurlProxyConfig::disabled()), - _ => { - validate_proxy_url(normalized)?; - Ok(CurlProxyConfig::url(normalized.to_string())) - } - } -} - -fn print_usage(binary: &str) { - eprintln!("BlueArchiveToolkit official resource sync"); - eprintln!(); - eprintln!("Usage:"); - eprintln!(" {binary} [OPTIONS]"); - eprintln!(" {binary} [OPTIONS]"); - eprintln!(); - eprintln!("Commands:"); - eprintln!(" refresh Run one update check, or ask a live daemon to refresh"); - eprintln!(" verify Verify remote plan, local manifest, and official seed hashes"); - eprintln!(" repair Redownload resources that fail local verification"); - eprintln!(" 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 Use an official server-info URL"); - eprintln!(" --server-info-file Use an official server-info file name"); - eprintln!(" --server-info-path Use a local server-info JSON file"); - eprintln!(" --app-version Override app version"); - eprintln!(" --connection-group Override connection group"); - eprintln!(" --launcher-version Launcher metadata API version (default: 1.7.2)"); - eprintln!(); - eprintln!("Sync:"); - eprintln!(" --platforms Platforms, e.g. Windows,Android"); - eprintln!( - " --output Official resource publish root (default: ./bat-resources)" - ); - eprintln!( - " --localized-output 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 CAS root for official release imports"); - eprintln!(" --import-resource-db SQLite ResourceRepository path"); - eprintln!(" --snapshot Override snapshot path (default: /current/official-sync-snapshot.json)"); - eprintln!(" --curl curl executable (default: curl)"); - eprintln!( - " --download-concurrency Bounded parallel downloads (default: 8, range 1..=256)" - ); - eprintln!(" --proxy curl proxy override (default: auto from env)"); - eprintln!(" --no-proxy Force direct curl connections"); - eprintln!(" --unzip 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 Query offset for resource-index/parse-text-units/parse-errors/translation-tasks"); - eprintln!(" --limit Query limit for resource-index/parse-text-units/parse-errors/translation-tasks (1..=1000)"); - eprintln!(" --task-id Filter translation-tasks by stable task ID"); - eprintln!(" --resource-type asset_bundle, manifest, table_bundle, text_asset, media, other"); - eprintln!(" --hash Filter resource-index by full CAS hash"); - eprintln!( - " --path-pattern Filter resource-index or parse detail by path pattern" - ); - eprintln!(" --release-id Filter resource-index or translation-tasks by official release ID"); - eprintln!(" --platform Filter resource-index by metadata platform"); - eprintln!(" --destination Filter resource-index, parse detail, or translation-tasks by official destination"); - eprintln!(" --bundle-path Filter resource-index by metadata bundle path"); - eprintln!(" --archive-entry Filter resource-index, parse detail, or translation-tasks by ZIP/archive entry"); - eprintln!(" --task-status Filter translation-tasks by task status"); - eprintln!( - " --worker-status Filter translation-tasks by provider worker status" - ); - eprintln!(" --parse-status Filter resource-index or translation-tasks by parse status"); - eprintln!(" --path-id Filter parse detail by Unity object path ID"); - eprintln!(" --class-id Filter parse detail by Unity class ID"); - eprintln!(" --field-path Filter parse detail, or TypeTree field path after UnityFS field patch commands"); - eprintln!(" --format 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 Patch type for patch-apply"); - eprintln!(" --source-file Source file for patch-apply"); - eprintln!(" --patch-file Patch JSON file for patch-apply"); - eprintln!(" --bundle-file Source UnityFS bundle file"); - eprintln!(" --serialized-file Serialized file path inside UnityFS"); - eprintln!(" --object-path-id Unity object path ID for UnityFS patch"); - eprintln!( - " --string-field-path Deprecated alias for UnityFS TypeTree field path" - ); - eprintln!(" --replacement-file Replacement bytes or UTF-8 string file"); - eprintln!(" --replacement-text Inline replacement text for string-field patch"); - eprintln!( - " --replacement-json Semantic replacement, e.g. signed/enum/bit_field JSON" - ); - eprintln!(" --expected-name Expected TextAsset name"); - eprintln!(" --expected-value Expected source string value"); - eprintln!(" --expected-json Optional expected semantic source value"); - eprintln!(" --target-file Target output file written atomically"); - eprintln!(); - eprintln!("Daemon:"); - eprintln!(" --watch Run in foreground loop"); - eprintln!(" --daemon Start detached watch process"); - eprintln!(" --state-dir Daemon state dir (default: /tmp/bat-pid)"); - eprintln!(" --interval Normal check interval (default: 1h)"); - eprintln!(" --error-retry Retry interval after error (default: 60s)"); - eprintln!(" --quiet-up-to-date Suppress clean up-to-date reports"); - eprintln!(" --no-quiet-up-to-date Always print reports"); - eprintln!(" --tail Log lines for logs command (default: 200)"); - eprintln!(); - eprintln!("Output:"); - eprintln!(" --human Human-readable output (default)"); - eprintln!(" --json Stable JSON output for scripts"); - eprintln!(" --progress | --no-progress Enable/disable stderr progress logs"); - eprintln!(" --banner | --no-banner Enable/disable startup banner"); - eprintln!(" -h, --help Show this help"); - eprintln!(); - eprintln!("Defaults:"); - eprintln!(" platforms: Windows,Android"); - eprintln!( - " official resource output: ./bat-resources (current -> versions/, .staging/)" - ); - 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 { - let value = value.trim(); - if value.is_empty() { - return Err(anyhow::anyhow!("时间间隔不能为空")); - } - - let (number, multiplier) = if let Some(number) = value.strip_suffix("ms") { - let millis = number - .parse::() - .map_err(|error| anyhow::anyhow!("毫秒时间间隔无效:{value}:{error}"))?; - return Ok(Duration::from_millis(millis)); - } else if let Some(number) = value.strip_suffix('s') { - (number, 1) - } else if let Some(number) = value.strip_suffix('m') { - (number, 60) - } else if let Some(number) = value.strip_suffix('h') { - (number, 60 * 60) - } else { - (value, 1) - }; - - let amount = number - .parse::() - .map_err(|error| anyhow::anyhow!("时间间隔无效:{value}:{error}"))?; - Ok(Duration::from_secs(amount.saturating_mul(multiplier))) -} - -fn format_duration(duration: Duration) -> String { - let total_millis = duration.as_millis(); - let minutes = total_millis / 60_000; - let seconds = (total_millis / 1_000) % 60; - let millis = total_millis % 1_000; - format!("{minutes:02}:{seconds:02}.{millis:03}") -} - -fn parse_platforms(value: &str) -> Result, String> { - value - .split(',') - .map(str::trim) - .filter(|part| !part.is_empty()) - .map(parse_platform) - .collect() -} - -fn parse_platform(value: &str) -> Result { - match value.to_ascii_lowercase().as_str() { - "windows" | "win" => Ok(PatchPlatform::Windows), - "android" => Ok(PatchPlatform::Android), - _ => Err(format!("不支持的平台:{value}")), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn parse(values: &[&str]) -> anyhow::Result { - parse_args_from(values.iter().map(|value| value.to_string())) - } - - fn parse_with_env(values: &[&str], env: &[(&str, &str)]) -> anyhow::Result { - let map: HashMap = env - .iter() - .map(|(key, value)| (key.to_string(), value.to_string())) - .collect(); - parse_args_with_env(values.iter().map(|value| value.to_string()), move |key| { - map.get(key).cloned() - }) - } - - #[test] - fn env_defaults_apply_and_cli_overrides() { - assert_eq!( - CliOptions::default().config.download_concurrency, - DEFAULT_DOWNLOAD_CONCURRENCY - ); - let options = parse_with_env( - &["bat"], - &[ - ("BAT_OUTPUT", "/srv/bat"), - ("BAT_LOCALIZED_OUTPUT", "/srv/bat-localized"), - ("BAT_IMPORT_REPOSITORY", "1"), - ("BAT_IMPORT_CAS_ROOT", "/srv/bat-cas"), - ("BAT_IMPORT_RESOURCE_DB", "/srv/bat/resources.sqlite"), - ("BAT_AUTO_DISCOVER", "1"), - ("BAT_STATE_DIR", "/srv/state"), - ("BAT_INTERVAL_SECONDS", "120"), - ("BAT_DOWNLOAD_CONCURRENCY", "3"), - ], - ) - .unwrap(); - assert_eq!(options.config.output_root, PathBuf::from("/srv/bat")); - assert_eq!( - options.config.localized_output_root, - PathBuf::from("/srv/bat-localized") - ); - assert!(options.config.import_repository); - assert_eq!( - options.config.import_cas_root, - Some(PathBuf::from("/srv/bat-cas")) - ); - assert_eq!( - options.config.import_resource_repository_path, - Some(PathBuf::from("/srv/bat/resources.sqlite")) - ); - assert!(options.config.auto_discover); - assert_eq!(options.config.download_concurrency, 3); - assert_eq!(options.state_dir, PathBuf::from("/srv/state")); - assert_eq!(options.interval, Duration::from_secs(120)); - - // 命令行覆盖环境变量。 - let options = parse_with_env( - &["bat", "--output", "/cli/out"], - &[("BAT_OUTPUT", "/srv/bat")], - ) - .unwrap(); - assert_eq!(options.config.output_root, PathBuf::from("/cli/out")); - - // 空值视为未设置(模板中保留 `BAT_XXX=` 空行无副作用)。 - let options = parse_with_env(&["bat"], &[("BAT_OUTPUT", "")]).unwrap(); - assert_eq!( - options.config.output_root, - CliOptions::default().config.output_root - ); - } - - #[test] - fn env_watch_daemon_only_affect_bare_run() { - let options = parse_with_env(&["bat"], &[("BAT_WATCH", "1")]).unwrap(); - assert!(options.watch); - - // 同时设置时 daemon 优先(daemon 自带 watch)。 - let options = parse_with_env(&["bat"], &[("BAT_WATCH", "1"), ("BAT_DAEMON", "1")]).unwrap(); - assert!(options.daemon); - assert!(!options.watch); - - // 子命令不受影响:verify 内部置 dry-run,若误吃 env watch 会直接解析失败。 - let options = parse_with_env(&["bat", "verify"], &[("BAT_WATCH", "1")]).unwrap(); - assert!(!options.watch); - assert!(!options.daemon); - - // 命令行显式 --dry-run 时 env watch 让位(命令行意图优先)。 - let options = parse_with_env(&["bat", "--dry-run"], &[("BAT_WATCH", "1")]).unwrap(); - assert!(!options.watch); - - // 命令行显式选择前台 watch 时 env daemon 让位。 - let options = parse_with_env(&["bat", "--watch"], &[("BAT_DAEMON", "1")]).unwrap(); - assert!(options.watch); - assert!(!options.daemon); - } - - #[test] - fn env_values_do_not_break_status_and_reload_guard() { - // .env 提供的代理/工具/输出目录不算"显式同步参数",status 应照常可用。 - let options = parse_with_env( - &["bat", "status"], - &[ - ("BAT_PROXY", "http://127.0.0.1:7897"), - ("BAT_CURL", "/usr/bin/curl"), - ("BAT_OUTPUT", "/srv/bat"), - ], - ) - .unwrap(); - assert_eq!(options.command, CliCommand::Status); - assert!(!tools_are_non_default( - &options.config, - &options.env_baseline_config - )); - - // 命令行再改工具才算偏离基线。 - let options = parse_with_env( - &["bat", "--curl", "/opt/curl"], - &[("BAT_PROXY", "http://127.0.0.1:7897")], - ) - .unwrap(); - assert!(tools_are_non_default( - &options.config, - &options.env_baseline_config - )); - } - - #[test] - fn env_invalid_values_error() { - assert!(parse_with_env(&["bat"], &[("BAT_WATCH", "maybe")]).is_err()); - assert!(parse_with_env(&["bat"], &[("BAT_INTERVAL_SECONDS", "abc")]).is_err()); - assert!(parse_with_env(&["bat"], &[("BAT_PROXY", "ftp://x")]).is_err()); - assert!(parse_with_env(&["bat"], &[("BAT_DOWNLOAD_CONCURRENCY", "0")]).is_err()); - assert!(parse_with_env(&["bat"], &[("BAT_DOWNLOAD_CONCURRENCY", "257")]).is_err()); - assert!(parse(&["bat", "--download-concurrency", "0"]).is_err()); - assert!(parse(&["bat", "--download-concurrency", "257"]).is_err()); - } - - #[test] - fn cli_download_concurrency_is_preserved_for_daemon_child() { - let options = parse(&["bat", "--download-concurrency", "4"]).unwrap(); - assert_eq!(options.config.download_concurrency, 4); - let args = daemon_child_args(&options); - assert!(args - .windows(2) - .any(|pair| pair == ["--download-concurrency", "4"])); - } - - #[test] - fn parse_env_line_handles_quotes_and_rejects_bad_keys() { - assert_eq!( - parse_env_line("KEY=value"), - Some(("KEY".to_string(), "value".to_string())) - ); - assert_eq!( - parse_env_line("KEY=\"quoted value\""), - Some(("KEY".to_string(), "quoted value".to_string())) - ); - assert_eq!( - parse_env_line("KEY='single'"), - Some(("KEY".to_string(), "single".to_string())) - ); - assert_eq!( - parse_env_line("BAT_OUTPUT = ./x"), - Some(("BAT_OUTPUT".to_string(), "./x".to_string())) - ); - assert_eq!(parse_env_line("no_equals_sign"), None); - assert_eq!(parse_env_line("1BAD=x"), None); - assert_eq!(parse_env_line("BAD KEY=x"), None); - } - - #[test] - fn env_template_is_parseable_and_bootstrap_ready() { - // 模板每个非注释行必须可解析;无参启动所需的最小配置默认启用。 - let mut keys = Vec::new(); - for line in ENV_TEMPLATE.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, _) = - parse_env_line(line).unwrap_or_else(|| panic!("模板行必须可解析:{line}")); - keys.push(key); - } - assert!(keys.contains(&"BAT_OUTPUT".to_string())); - assert!(keys.contains(&"BAT_LOCALIZED_OUTPUT".to_string())); - assert!(keys.contains(&"BAT_IMPORT_REPOSITORY".to_string())); - assert!(keys.contains(&"BAT_IMPORT_CAS_ROOT".to_string())); - assert!(keys.contains(&"BAT_IMPORT_RESOURCE_DB".to_string())); - assert!(keys.contains(&"BAT_AUTO_DISCOVER".to_string())); - } - - #[test] - fn task_persistence_round_trip_and_interrupt_marking() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path(); - - let (registry, summary) = TaskRegistry::with_persistence(state_dir); - assert!(summary.contains("空任务历史"), "{summary}"); - let finished_id = registry.create(TaskKind::Sync); - registry.update(&finished_id, |record| { - record.status = "running"; - record.started_at = Some(record.created_at); - }); - registry.append_log(&finished_id, "进度 1".to_string()); - registry.update(&finished_id, |record| { - record.status = "succeeded"; - record.finished_at = Some(record.created_at + 1); - record.result = Some(serde_json::json!({ "ok": true })); - }); - let running_id = registry.create(TaskKind::Verify); - registry.update(&running_id, |record| record.status = "running"); - let failed_id = registry.create(TaskKind::Refresh); - registry.update(&failed_id, |record| { - record.status = "failed"; - record.error = Some(ApiError::new( - ErrorCode::HTTP_NOT_FOUND, - "task.executor", - "404", - )); - }); - let repair_id = registry.create(TaskKind::Repair); - registry.update(&repair_id, |record| record.status = "succeeded"); - drop(registry); - - // 重启:恢复历史;running 任务标记中断;错误码经持久化往返保留。 - let (registry, summary) = TaskRegistry::with_persistence(state_dir); - assert!(summary.contains("恢复任务历史 4 条"), "{summary}"); - assert!(summary.contains("标记中断 1 条"), "{summary}"); - let finished = registry.get(&finished_id).unwrap(); - assert_eq!(finished.status, "succeeded"); - assert_eq!(finished.result, Some(serde_json::json!({ "ok": true }))); - assert_eq!( - registry.logs(&finished_id).unwrap(), - vec!["进度 1".to_string()] - ); - let interrupted = registry.get(&running_id).unwrap(); - assert_eq!(interrupted.status, "failed"); - assert_eq!( - interrupted.error.as_ref().unwrap().code(), - ErrorCode::TASK_INTERRUPTED - ); - assert!(interrupted.finished_at.is_some()); - let failed = registry.get(&failed_id).unwrap(); - assert_eq!( - failed.error.as_ref().unwrap().code(), - ErrorCode::HTTP_NOT_FOUND - ); - let repair = registry.get(&repair_id).unwrap(); - assert_eq!(repair.kind, "resource.repair"); - assert_eq!(repair.status, "succeeded"); - - // seq 持久化:重启后(本测试内 pid 相同)新任务不与历史撞 ID。 - let new_id = registry.create(TaskKind::Sync); - assert!( - [&finished_id, &running_id, &failed_id, &repair_id] - .iter() - .all(|id| **id != new_id), - "新任务 ID {new_id} 与历史撞号" - ); - assert_eq!(registry.list().len(), 5); - } - - #[test] - fn task_persistence_recovers_from_corrupt_file() { - let temp = tempfile::TempDir::new().unwrap(); - fs::write(temp.path().join(TASKS_FILE_NAME), b"not-json").unwrap(); - - let (registry, summary) = TaskRegistry::with_persistence(temp.path()); - assert!(summary.contains("任务历史不可用"), "{summary}"); - // 损坏文件改名留证,不静默覆盖。 - assert!(temp.path().join("bat-tasks.json.corrupt").exists()); - assert!(registry.list().is_empty()); - - // 恢复后可正常写入新历史。 - registry.create(TaskKind::Sync); - let bytes = fs::read(temp.path().join(TASKS_FILE_NAME)).unwrap(); - let file: PersistedTaskFile = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(file.version, TASKS_FILE_VERSION); - assert_eq!(file.tasks.len(), 1); - assert_eq!(file.tasks[0].status, "queued"); - } - - #[test] - fn task_persistence_skips_unknown_kinds() { - let temp = tempfile::TempDir::new().unwrap(); - let file = serde_json::json!({ - "version": TASKS_FILE_VERSION, - "seq": 9, - "tasks": [ - { - "id": "task-1-1", - "kind": "patch.apply", - "status": "succeeded", - "created_at": 1, - "updated_at": 2 - }, - { - "id": "task-1-2", - "kind": "resource.sync", - "status": "succeeded", - "created_at": 3, - "updated_at": 4 - } - ] - }); - fs::write( - temp.path().join(TASKS_FILE_NAME), - serde_json::to_vec(&file).unwrap(), - ) - .unwrap(); - - let (registry, summary) = TaskRegistry::with_persistence(temp.path()); - assert!(summary.contains("跳过无法识别 1 条"), "{summary}"); - assert!(registry.get("task-1-1").is_none()); - assert_eq!(registry.get("task-1-2").unwrap().status, "succeeded"); - } - - #[test] - fn parses_auto_discover_sync_args() { - let options = parse(&[ - "bat", - "--auto-discover", - "--platforms", - "Windows,Android", - "--snapshot", - "/tmp/snapshot.json", - "--import-repository", - "--import-cas-root", - "/tmp/bat-cas", - "--import-resource-db", - "/tmp/bat-resources.sqlite", - "--dry-run", - "--plan", - ]) - .unwrap(); - let config = options.config; - - assert!(config.auto_discover); - assert_eq!( - config.platforms.as_deref(), - Some([PatchPlatform::Windows, PatchPlatform::Android].as_slice()) - ); - assert_eq!( - config.snapshot_path, - Some(PathBuf::from("/tmp/snapshot.json")) - ); - assert!(config.import_repository); - assert_eq!(config.import_cas_root, Some(PathBuf::from("/tmp/bat-cas"))); - assert_eq!( - config.import_resource_repository_path, - Some(PathBuf::from("/tmp/bat-resources.sqlite")) - ); - assert!(config.dry_run); - assert!(config.plan); - assert!(config.audit_local); - assert!(config.repair); - assert!(!options.watch); - assert_eq!( - options.interval, - Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS) - ); - assert_eq!( - options.error_retry_interval, - Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS) - ); - assert!(!options.quiet_up_to_date); - assert!(!options.quiet_up_to_date_explicit); - assert!(options.progress); - assert!(options.banner); - assert_eq!(options.output_format, OutputFormat::Human); - assert_eq!(config.output_root, PathBuf::from("./bat-resources")); - assert_eq!( - config.localized_output_root, - PathBuf::from("./bat-localized") - ); - assert_eq!(options.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR)); - } - - #[test] - fn parses_explicit_source_and_disable_repair() { - let options = parse(&[ - "bat", - "--server-info-url", - "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json", - "--connection-group", - "Prod-Audit", - "--app-version", - "1.70.0", - "--no-audit-local", - "--no-repair", - "--unzip", - "/usr/bin/unzip", - ]) - .unwrap(); - let config = options.config; - - assert!(!config.auto_discover); - assert_eq!(config.connection_group.as_deref(), Some("Prod-Audit")); - assert_eq!(config.app_version.as_deref(), Some("1.70.0")); - assert!(!config.audit_local); - assert!(!config.repair); - assert_eq!(config.unzip_command, PathBuf::from("/usr/bin/unzip")); - assert!(matches!( - config.server_info_source, - Some(OfficialServerInfoSource::OfficialUrl(ref url)) - if url == "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" - )); - } - - #[test] - fn parses_proxy_options() { - let options = parse(&["bat", "--proxy", "http://127.0.0.1:7890"]).unwrap(); - assert!(options.proxy_option_explicit); - assert_eq!( - options.config.curl_proxy.mode(), - &CurlProxyMode::Url("http://127.0.0.1:7890".to_string()) - ); - - let options = parse(&["bat", "--proxy", "none"]).unwrap(); - assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Disabled); - - let options = parse(&["bat", "--no-proxy"]).unwrap(); - assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Disabled); - - let options = parse(&["bat", "--proxy", "auto"]).unwrap(); - assert!(options.proxy_option_explicit); - assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Auto); - - // socks5 等受支持 scheme 可用。 - let options = parse(&["bat", "--proxy", "socks5://127.0.0.1:1080"]).unwrap(); - assert_eq!( - options.config.curl_proxy.mode(), - &CurlProxyMode::Url("socks5://127.0.0.1:1080".to_string()) - ); - } - - #[test] - fn rejects_proxy_with_unsupported_scheme() { - let error = parse(&["bat", "--proxy", "htp://127.0.0.1:7890"]).unwrap_err(); - assert!(error.to_string().contains("不支持的 scheme")); - - // 无 scheme 的 host:port 仍接受(curl 默认按 http 处理)。 - assert!(parse(&["bat", "--proxy", "127.0.0.1:7890"]).is_ok()); - } - - #[test] - fn parses_watch_defaults_to_one_hour_and_quiet_up_to_date() { - let options = parse(&["bat", "--auto-discover", "--watch", "--interval", "30m"]).unwrap(); - - assert!(options.watch); - assert_eq!(options.interval, Duration::from_secs(30 * 60)); - assert_eq!( - options.error_retry_interval, - Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS) - ); - assert!(options.quiet_up_to_date); - assert!(!options.quiet_up_to_date_explicit); - } - - #[test] - fn parses_daemon_mode_as_background_watch() { - let options = parse(&[ - "bat", - "--auto-discover", - "--daemon", - "--output", - "/tmp/daemon-output", - "--localized-output", - "/tmp/daemon-localized", - "--interval", - "30m", - ]) - .unwrap(); - - assert_eq!(options.command, CliCommand::Run); - assert!(options.daemon); - assert!(!options.watch); - assert_eq!( - options.config.output_root, - PathBuf::from("/tmp/daemon-output") - ); - assert_eq!( - options.config.localized_output_root, - PathBuf::from("/tmp/daemon-localized") - ); - assert_eq!(options.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR)); - assert_eq!(options.interval, Duration::from_secs(30 * 60)); - assert!(options.quiet_up_to_date); - } - - #[test] - fn parses_output_format_flags() { - let options = parse(&["bat", "status", "--json"]).unwrap(); - assert_eq!(options.command, CliCommand::Status); - assert_eq!(options.output_format, OutputFormat::Json); - - let options = parse(&["bat", "status", "--json", "--human"]).unwrap(); - assert_eq!(options.output_format, OutputFormat::Human); - } - - #[test] - fn parses_status_and_stop_commands() { - let status = parse(&["bat", "status"]).unwrap(); - assert_eq!(status.command, CliCommand::Status); - assert!(!status.watch); - assert!(!status.daemon); - assert!(!status.progress); - assert!(!status.banner); - assert_eq!(status.config.output_root, PathBuf::from("./bat-resources")); - assert_eq!( - status.config.localized_output_root, - PathBuf::from("./bat-localized") - ); - assert_eq!(status.state_dir, PathBuf::from(DEFAULT_DAEMON_STATE_DIR)); - - let stop = parse(&["bat", "stop", "--state-dir", "/tmp/custom-bat-pid"]).unwrap(); - assert_eq!(stop.command, CliCommand::Stop); - assert_eq!(stop.config.output_root, PathBuf::from("./bat-resources")); - assert_eq!( - stop.config.localized_output_root, - PathBuf::from("./bat-localized") - ); - assert_eq!(stop.state_dir, PathBuf::from("/tmp/custom-bat-pid")); - } - - #[test] - fn parses_management_and_resource_commands() { - let refresh = parse(&["bat", "refresh", "--force"]).unwrap(); - assert_eq!(refresh.command, CliCommand::Refresh); - assert!(refresh.config.auto_discover); - assert!(refresh.config.force); - - let verify = parse(&["bat", "verify"]).unwrap(); - assert_eq!(verify.command, CliCommand::Verify); - assert!(verify.config.auto_discover); - assert!(verify.config.dry_run); - assert!(verify.config.plan); - assert!(!verify.config.repair); - - let repair = parse(&["bat", "repair"]).unwrap(); - assert_eq!(repair.command, CliCommand::Repair); - assert!(repair.config.auto_discover); - - for (value, expected) in [ - ("restart", CliCommand::Restart), - ("reload", CliCommand::Reload), - ("logs", CliCommand::Logs), - ("doctor", CliCommand::Doctor), - ("clean-stable", CliCommand::CleanStable), - ("parse-status", CliCommand::ParseStatus), - ("parse-text-units", CliCommand::ParseTextUnits), - ("parse-errors", CliCommand::ParseErrors), - ("translation-tasks", CliCommand::TranslationTasks), - ("localized-status", CliCommand::LocalizedStatus), - ("resource-index", CliCommand::ResourceIndex), - ] { - let options = parse(&["bat", value]).unwrap(); - assert_eq!(options.command, expected); - } - - let index = parse(&[ - "bat", - "resource-index", - "--resource-type", - "text_asset", - "--hash", - "abc", - "--path-pattern", - "TextAssets/**", - "--release-id", - "v-current", - "--platform", - "windows", - "--destination", - "TextAssets/Scenario.json", - "--bundle-path", - "Bundles/story.bundle", - "--archive-entry", - "story/Scenario.json", - "--parse-status", - "parsed", - "--format", - "json", - "--offset", - "5", - "--limit", - "25", - ]) - .unwrap(); - assert_eq!(index.command, CliCommand::ResourceIndex); - assert_eq!(index.query_resource_type, Some(ResourceType::TextAsset)); - assert_eq!(index.query_hash.as_deref(), Some("abc")); - assert_eq!(index.query_path_pattern.as_deref(), Some("TextAssets/**")); - assert_eq!( - index.query_official_release_id.as_deref(), - Some("v-current") - ); - assert_eq!(index.query_platform.as_deref(), Some("windows")); - assert_eq!( - index.query_destination.as_deref(), - Some("TextAssets/Scenario.json") - ); - assert_eq!( - index.query_bundle_path.as_deref(), - Some("Bundles/story.bundle") - ); - assert_eq!( - index.query_archive_entry.as_deref(), - Some("story/Scenario.json") - ); - assert_eq!(index.query_parse_status.as_deref(), Some("parsed")); - assert_eq!(index.query_format.as_deref(), Some("json")); - assert_eq!(index.query_offset, 5); - assert_eq!(index.query_limit, 25); - - let text_units = parse(&[ - "bat", - "parse-text-units", - "--destination", - "Bundle/test.bundle", - "--archive-entry", - "assets/scenario.bundle", - "--path-id", - "42", - "--class-id", - "114", - "--field-path", - "Scenario.Message", - "--format", - "plain", - "--path-pattern", - "Bundle/**", - "--offset", - "2", - "--limit", - "10", - ]) - .unwrap(); - assert_eq!(text_units.command, CliCommand::ParseTextUnits); - assert_eq!( - text_units.query_destination.as_deref(), - Some("Bundle/test.bundle") - ); - assert_eq!(text_units.query_path_id, Some(42)); - assert_eq!(text_units.query_class_id, Some(114)); - assert_eq!( - text_units.query_field_path.as_deref(), - Some("Scenario.Message") - ); - assert_eq!(text_units.query_format.as_deref(), Some("plain")); - assert_eq!(text_units.query_offset, 2); - assert_eq!(text_units.query_limit, 10); - - let tasks = parse(&[ - "bat", - "translation-tasks", - "--task-id", - "textunit/v-current/TextAssets/Scenario.json", - "--release-id", - "v-current", - "--destination", - "TextAssets/Scenario.json", - "--archive-entry", - "story/Scenario.json", - "--task-status", - "skipped_parse_failed", - "--parse-status", - "failed", - "--format", - "json", - "--has-reason", - "--offset", - "1", - "--limit", - "20", - ]) - .unwrap(); - assert_eq!(tasks.command, CliCommand::TranslationTasks); - assert_eq!( - tasks.query_task_id.as_deref(), - Some("textunit/v-current/TextAssets/Scenario.json") - ); - assert_eq!( - tasks.query_official_release_id.as_deref(), - Some("v-current") - ); - assert_eq!( - tasks.query_destination.as_deref(), - Some("TextAssets/Scenario.json") - ); - assert_eq!( - tasks.query_archive_entry.as_deref(), - Some("story/Scenario.json") - ); - assert_eq!( - tasks.query_task_status.as_deref(), - Some("skipped_parse_failed") - ); - assert_eq!(tasks.query_parse_status.as_deref(), Some("failed")); - assert_eq!(tasks.query_format.as_deref(), Some("json")); - assert_eq!(tasks.query_has_reason, Some(true)); - assert_eq!(tasks.query_offset, 1); - assert_eq!(tasks.query_limit, 20); - - let error = parse(&["bat", "parse-status", "--limit", "10"]).unwrap_err(); - assert!(error.to_string().contains("查询过滤参数只适用于")); - - let error = parse(&["bat", "parse-text-units", "--hash", "abc"]).unwrap_err(); - assert!(error.to_string().contains("--resource-type/--hash")); - - let error = parse(&["bat", "resource-index", "--path-id", "42"]).unwrap_err(); - assert!(error - .to_string() - .contains("--path-id/--class-id/--field-path")); - - let error = - parse(&["bat", "translation-tasks", "--resource-type", "text_asset"]).unwrap_err(); - assert!(error - .to_string() - .contains("--resource-type/--hash/--platform/--bundle-path")); - } - - #[test] - fn parses_write_patch_commands_without_output_confusion() { - let patch = parse(&[ - "bat", - "patch-apply", - "--patch-kind", - "text", - "--source-file", - "/tmp/source.txt", - "--patch-file", - "/tmp/source.patch.json", - "--target-file", - "/tmp/target.txt", - ]) - .unwrap(); - assert_eq!(patch.command, CliCommand::PatchApply); - assert_eq!(patch.patch_kind, Some(PatchApplyKind::Text)); - assert_eq!( - patch.patch_source_path, - Some(PathBuf::from("/tmp/source.txt")) - ); - - let text_asset = parse(&[ - "bat", - "unityfs-patch-text-asset", - "--bundle-file", - "/tmp/source.bundle", - "--serialized-file", - "CAB-1", - "--object-path-id", - "7", - "--replacement-file", - "/tmp/replacement.bytes", - "--expected-name", - "Scenario", - "--target-file", - "/tmp/target.bundle", - ]) - .unwrap(); - assert_eq!(text_asset.command, CliCommand::UnityFsPatchTextAsset); - assert_eq!(text_asset.unityfs_path_id, Some(7)); - assert_eq!( - text_asset.unityfs_expected_name.as_deref(), - Some("Scenario") - ); - - let string_field = parse(&[ - "bat", - "unityfs-patch-string-field", - "--bundle-file", - "/tmp/source.bundle", - "--serialized-file", - "CAB-1", - "--object-path-id", - "9", - "--string-field-path", - "entry.message", - "--replacement-text", - "老師", - "--expected-value", - "先生", - "--target-file", - "/tmp/target.bundle", - ]) - .unwrap(); - assert_eq!(string_field.command, CliCommand::UnityFsPatchStringField); - assert_eq!( - string_field.unityfs_field_path.as_deref(), - Some("entry.message") - ); - assert_eq!( - string_field.unityfs_replacement_text.as_deref(), - Some("老師") - ); - - let field = parse(&[ - "bat", - "unityfs-patch-field", - "--bundle-file", - "/tmp/source.bundle", - "--serialized-file", - "CAB-1", - "--object-path-id", - "11", - "--field-path", - "scores[1]", - "--expected-json", - r#"{"kind":"signed","value":20}"#, - "--replacement-json", - r#"{"kind":"signed","value":42}"#, - "--target-file", - "/tmp/target.bundle", - ]) - .unwrap(); - assert_eq!(field.command, CliCommand::UnityFsPatchField); - assert_eq!(field.unityfs_field_path.as_deref(), Some("scores[1]")); - assert_eq!( - field.unityfs_expected_semantic_value, - Some(UnitySerializedReplacementValue::Signed(20)) - ); - assert_eq!( - field.unityfs_replacement_value, - Some(UnitySerializedReplacementValue::Signed(42)) - ); - - let enum_field = parse(&[ - "bat", - "unityfs-patch-field", - "--bundle-file", - "/tmp/source.bundle", - "--serialized-file", - "CAB-1", - "--object-path-id", - "12", - "--field-path", - "difficulty", - "--expected-json", - r#"{"kind":"enum","value":{"type_name":"ScenarioDifficulty","storage_type":"int","value":2}}"#, - "--replacement-json", - r#"{"kind":"enum","value":{"type_name":"ScenarioDifficulty","storage_type":"int","value":3}}"#, - "--target-file", - "/tmp/target.bundle", - ]) - .unwrap(); - assert_eq!(enum_field.command, CliCommand::UnityFsPatchField); - assert_eq!( - enum_field.unityfs_expected_semantic_value, - Some(UnitySerializedReplacementValue::Enum { - type_name: "ScenarioDifficulty".to_string(), - storage_type: "int".to_string(), - value: 2, - }) - ); - assert_eq!( - enum_field.unityfs_replacement_value, - Some(UnitySerializedReplacementValue::Enum { - type_name: "ScenarioDifficulty".to_string(), - storage_type: "int".to_string(), - value: 3, - }) - ); - - let bitfield_field = parse(&[ - "bat", - "unityfs-patch-field", - "--bundle-file", - "/tmp/source.bundle", - "--serialized-file", - "CAB-1", - "--object-path-id", - "13", - "--field-path", - "target_layers", - "--expected-json", - r#"{"kind":"bit_field","value":{"type_name":"LayerMask","storage_type":"UInt32","bits":5}}"#, - "--replacement-json", - r#"{"kind":"bit_field","value":{"type_name":"LayerMask","storage_type":"UInt32","bits":9}}"#, - "--target-file", - "/tmp/target.bundle", - ]) - .unwrap(); - assert_eq!(bitfield_field.command, CliCommand::UnityFsPatchField); - assert_eq!( - bitfield_field.unityfs_expected_semantic_value, - Some(UnitySerializedReplacementValue::BitField { - type_name: "LayerMask".to_string(), - storage_type: "UInt32".to_string(), - bits: 5, - }) - ); - assert_eq!( - bitfield_field.unityfs_replacement_value, - Some(UnitySerializedReplacementValue::BitField { - type_name: "LayerMask".to_string(), - storage_type: "UInt32".to_string(), - bits: 9, - }) - ); - - let map_field = parse(&[ - "bat", - "unityfs-patch-field", - "--bundle-file", - "/tmp/source.bundle", - "--serialized-file", - "CAB-1", - "--object-path-id", - "12", - "--field-path", - "texts", - "--replacement-json", - r#"{"kind":"map","value":[{"kind":"object","value":[{"name":"first","value":{"kind":"string","value":"jp"}},{"name":"second","value":{"kind":"string","value":"こんにちは"}}]}]}"#, - "--target-file", - "/tmp/target.bundle", - ]) - .unwrap(); - assert_eq!(map_field.command, CliCommand::UnityFsPatchField); - assert_eq!(map_field.unityfs_field_path.as_deref(), Some("texts")); - assert_eq!( - map_field.unityfs_replacement_value, - Some(UnitySerializedReplacementValue::Map(vec![ - UnitySerializedReplacementValue::Object(vec![ - bat_assetbundle::UnitySerializedFieldReplacement { - name: "first".to_string(), - value: UnitySerializedReplacementValue::String("jp".to_string()), - }, - bat_assetbundle::UnitySerializedFieldReplacement { - name: "second".to_string(), - value: UnitySerializedReplacementValue::String("こんにちは".to_string()), - }, - ]), - ])) - ); - - let error = parse(&[ - "bat", - "patch-apply", - "--patch-kind", - "text", - "--source-file", - "/tmp/source.txt", - "--patch-file", - "/tmp/source.patch.json", - "--output", - "/tmp/wrong", - "--target-file", - "/tmp/target.txt", - ]) - .unwrap_err(); - assert!(error.to_string().contains("--target-file")); - } - - #[test] - fn parses_logs_tail_and_rejects_force_for_verify() { - let options = parse(&["bat", "logs", "--tail", "25"]).unwrap(); - assert_eq!(options.tail_lines, 25); - - let error = parse(&["bat", "verify", "--force"]).unwrap_err(); - assert!(error.to_string().contains("不能和 --force")); - } - - #[test] - fn restart_with_explicit_options_defaults_to_auto_discover() { - let options = parse(&["bat", "restart", "--output", "/tmp/bat-resources"]).unwrap(); - - assert_eq!(options.command, CliCommand::Restart); - assert!(options.output_explicit); - assert!(options.config.auto_discover); - } - - #[test] - fn daemon_child_args_preserve_sync_options() { - let options = parse(&[ - "bat", - "--auto-discover", - "--daemon", - "--platforms", - "Windows,Android", - "--output", - "/tmp/daemon-output", - "--localized-output", - "/tmp/daemon-localized", - "--import-repository", - "--import-cas-root", - "/tmp/daemon-cas", - "--import-resource-db", - "/tmp/daemon-resources.sqlite", - "--state-dir", - "/tmp/daemon-state", - "--curl", - "/usr/bin/curl", - "--proxy", - "http://127.0.0.1:7890", - "--unzip", - "/usr/bin/unzip", - "--interval", - "30m", - "--error-retry", - "45s", - "--json", - "--no-banner", - ]) - .unwrap(); - - let args = daemon_child_args(&options); - assert!(args.contains(&"--watch".to_string())); - assert!(args.contains(&"--daemon-child".to_string())); - assert!(!args.contains(&"--daemon".to_string())); - assert!(args - .windows(2) - .any(|pair| pair == ["--output", "/tmp/daemon-output"])); - assert!(args - .windows(2) - .any(|pair| pair == ["--localized-output", "/tmp/daemon-localized"])); - assert!(args.contains(&"--import-repository".to_string())); - assert!(args - .windows(2) - .any(|pair| pair == ["--import-cas-root", "/tmp/daemon-cas"])); - assert!(args - .windows(2) - .any(|pair| pair == ["--import-resource-db", "/tmp/daemon-resources.sqlite"])); - assert!(args - .windows(2) - .any(|pair| pair == ["--state-dir", "/tmp/daemon-state"])); - assert!(args - .windows(2) - .any(|pair| pair == ["--platforms", "Windows,Android"])); - // 代理凭据不得进入子进程 argv:只放不含凭据的 flag,URL 经环境变量下传。 - assert!(args.contains(&PROXY_FROM_ENV_FLAG.to_string())); - assert!(!args.iter().any(|arg| arg.contains("127.0.0.1:7890"))); - assert!(!args.windows(2).any(|pair| pair[0] == "--proxy")); - assert!(args.windows(2).any(|pair| pair == ["--interval", "30m"])); - assert!(args.windows(2).any(|pair| pair == ["--error-retry", "45s"])); - assert!(args.contains(&"--quiet-up-to-date".to_string())); - assert!(args.contains(&"--json".to_string())); - assert!(args.contains(&"--no-banner".to_string())); - } - - #[test] - fn curl_proxy_url_extracts_only_url_mode() { - assert_eq!( - curl_proxy_url(&CurlProxyConfig::url("http://user:secret@127.0.0.1:7890")), - Some("http://user:secret@127.0.0.1:7890".to_string()) - ); - assert_eq!(curl_proxy_url(&CurlProxyConfig::auto()), None); - assert_eq!(curl_proxy_url(&CurlProxyConfig::disabled()), None); - } - - #[test] - fn daemon_proxy_secret_round_trip_and_clear() { - let dir = tempfile::TempDir::new().unwrap(); - let state_dir = dir.path(); - - assert_eq!(read_daemon_proxy_secret(state_dir).unwrap(), None); - - write_daemon_proxy_secret(state_dir, "http://user:secret@127.0.0.1:7890").unwrap(); - assert_eq!( - read_daemon_proxy_secret(state_dir).unwrap(), - Some("http://user:secret@127.0.0.1:7890".to_string()) - ); - // 凭据文件以 0600 创建。 - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = fs::metadata(daemon_proxy_secret_path(state_dir)) - .unwrap() - .permissions() - .mode() - & 0o777; - assert_eq!(mode, 0o600); - } - - clear_daemon_proxy_secret(state_dir); - assert_eq!(read_daemon_proxy_secret(state_dir).unwrap(), None); - } - - // 序列化会修改进程级 PROXY_URL_ENV_VAR 的测试,避免并行测试相互干扰。 - static PROXY_ENV_TEST_LOCK: Mutex<()> = Mutex::new(()); - - #[test] - fn proxy_from_env_flag_reads_and_consumes_env_var() { - let _guard = PROXY_ENV_TEST_LOCK - .lock() - .unwrap_or_else(|poison| poison.into_inner()); - env::set_var(PROXY_URL_ENV_VAR, "http://user:secret@127.0.0.1:7890"); - let options = parse(&["bat", "--watch", "--proxy-from-env"]).unwrap(); - assert!(options.proxy_option_explicit); - assert_eq!( - options.config.curl_proxy.mode(), - &CurlProxyMode::Url("http://user:secret@127.0.0.1:7890".to_string()) - ); - // 读取后应从环境中清除,避免被 curl 等孙进程继承。 - assert!(env::var(PROXY_URL_ENV_VAR).is_err()); - } - - #[test] - fn proxy_from_env_flag_errors_when_env_var_missing() { - let _guard = PROXY_ENV_TEST_LOCK - .lock() - .unwrap_or_else(|poison| poison.into_inner()); - env::remove_var(PROXY_URL_ENV_VAR); - let error = parse(&["bat", "--watch", "--proxy-from-env"]).unwrap_err(); - assert!(error.to_string().contains(PROXY_URL_ENV_VAR)); - } - - fn rpc_request(method: &str, params: Option) -> JsonRpcRequest { - JsonRpcRequest { - jsonrpc: Some("2.0".to_string()), - id: Some(serde_json::json!(1)), - method: method.to_string(), - params, - } - } - - #[test] - fn canonical_rpc_method_resolves_aliases() { - assert_eq!(canonical_rpc_method("bat.status"), RPC_METHOD_STATUS); - assert_eq!(canonical_rpc_method("bat.doctor"), RPC_METHOD_DOCTOR); - assert_eq!(canonical_rpc_method("bat.refresh"), RPC_METHOD_REFRESH); - assert_eq!(canonical_rpc_method("daemon.status"), RPC_METHOD_STATUS); - assert_eq!( - canonical_rpc_method("resource.list"), - RPC_METHOD_RESOURCE_MANIFEST - ); - assert_eq!(canonical_rpc_method("resource.state"), "resource.state"); - assert_eq!(canonical_rpc_method("unknown.method"), "unknown.method"); - } - - #[test] - fn is_pending_rpc_method_covers_planned_namespaces() { - assert!(is_pending_rpc_method("patch.build")); - assert!(is_pending_rpc_method("unityfs.inspect")); - assert!(is_pending_rpc_method("task.create")); - assert!(is_pending_rpc_method("daemon.clean-stable")); - // restart、sync/verify/repair、task.cancel/logs、catalog.*、 - // resource.manifest 和文件级 patch/unityfs 写入方法已实现。 - assert!(!is_pending_rpc_method("daemon.restart")); - assert!(!is_pending_rpc_method("patch.apply")); - assert!(!is_pending_rpc_method("unityfs.patch_text_asset")); - assert!(!is_pending_rpc_method("unityfs.patch_string_field")); - assert!(!is_pending_rpc_method("unityfs.patch_field")); - assert!(!is_pending_rpc_method("resource.sync")); - assert!(!is_pending_rpc_method("resource.verify")); - assert!(!is_pending_rpc_method("resource.repair")); - assert!(!is_pending_rpc_method("resource.manifest")); - assert!(!is_pending_rpc_method("resource.list")); - assert!(!is_pending_rpc_method("parse.status")); - assert!(!is_pending_rpc_method("parse.text_units")); - assert!(!is_pending_rpc_method("parse.errors")); - assert!(!is_pending_rpc_method("localized.status")); - assert!(!is_pending_rpc_method("catalog.status")); - assert!(!is_pending_rpc_method("catalog.refresh")); - assert!(!is_pending_rpc_method("task.cancel")); - assert!(!is_pending_rpc_method("task.logs")); - assert!(!is_pending_rpc_method("daemon.status")); - assert!(!is_pending_rpc_method("daemon.doctor")); - } - - #[test] - fn dispatch_daemon_restart_starts_controller_and_requests_stop() { - let temp = tempfile::TempDir::new().unwrap(); - let control = new_daemon_control(); - let envelope = dispatch_rpc_method( - &rpc_request("daemon.restart", None), - temp.path(), - &control, - &test_task_context(), - "req-restart-1".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["status"], "accepted"); - assert_eq!(value["data"]["command"], "restart"); - assert_eq!(value["data"]["controller_pid"], 4242); - assert_eq!( - wait_for_daemon_wake(Some(&control), Duration::from_millis(1)), - DaemonWake::Stop - ); - } - - #[test] - fn task_registry_cancel_and_logs() { - let registry = TaskRegistry::new(); - let id = registry.create(TaskKind::Sync); - - // 取消未结束任务:置标志。 - assert!(matches!( - registry.request_cancel(&id), - CancelOutcome::Requested - )); - assert!(registry.cancel_flag(&id).unwrap().load(Ordering::Relaxed)); - - // 追加日志(超上限时丢弃最旧)。 - for i in 0..(MAX_TASK_LOG_LINES + 5) { - registry.append_log(&id, format!("line {i}")); - } - let logs = registry.logs(&id).unwrap(); - assert_eq!(logs.len(), MAX_TASK_LOG_LINES); - assert_eq!( - logs.last().unwrap(), - &format!("line {}", MAX_TASK_LOG_LINES + 4) - ); - - // 已结束任务:AlreadyFinished;不存在:NotFound。 - registry.update(&id, |record| record.status = "succeeded"); - assert!(matches!( - registry.request_cancel(&id), - CancelOutcome::AlreadyFinished - )); - assert!(matches!( - registry.request_cancel("nope"), - CancelOutcome::NotFound - )); - } - - fn test_task_context() -> DaemonTaskContext { - test_task_context_with_config(OfficialUpdateConfig::default()) - } - - fn test_task_context_with_config(base_config: OfficialUpdateConfig) -> DaemonTaskContext { - let (queue, _rx) = mpsc::channel::(); - DaemonTaskContext { - registry: TaskRegistry::new(), - queue, - base_config, - restart_controller: test_restart_controller, - } - } - - fn test_restart_controller(_state_dir: &Path) -> anyhow::Result { - Ok(4242) - } - - #[test] - fn dispatch_unknown_method_returns_unknown_error_envelope() { - let temp = tempfile::TempDir::new().unwrap(); - let control = new_daemon_control(); - let envelope = dispatch_rpc_method( - &rpc_request("nope.nope", None), - temp.path(), - &control, - &test_task_context(), - "req-test-1".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], false); - assert_eq!(value["error"]["code"], "BAT-ERR-700001"); - assert_eq!(value["request_id"], "req-test-1"); - } - - #[test] - fn dispatch_pending_method_returns_not_implemented_envelope() { - let temp = tempfile::TempDir::new().unwrap(); - let control = new_daemon_control(); - let envelope = dispatch_rpc_method( - &rpc_request("patch.build", None), - temp.path(), - &control, - &test_task_context(), - "req-test-2".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], false); - assert_eq!(value["error"]["code"], "BAT-ERR-700003"); - } - - #[test] - fn dispatch_patch_apply_requires_params() { - let temp = tempfile::TempDir::new().unwrap(); - let control = new_daemon_control(); - let envelope = dispatch_rpc_method( - &rpc_request("patch.apply", None), - temp.path(), - &control, - &test_task_context(), - "req-test-patch-params".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], false); - assert_eq!(value["error"]["code"], "BAT-ERR-700002"); - } - - #[test] - fn dispatch_unityfs_patch_methods_are_live_handlers_and_require_params() { - let temp = tempfile::TempDir::new().unwrap(); - let control = new_daemon_control(); - for method in [ - "unityfs.patch_text_asset", - "unityfs.patch_string_field", - "unityfs.patch_field", - ] { - let envelope = dispatch_rpc_method( - &rpc_request(method, None), - temp.path(), - &control, - &test_task_context(), - format!("req-test-{method}"), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], false, "method={method}"); - assert_eq!(value["error"]["code"], "BAT-ERR-700002", "method={method}"); - assert_eq!(value["error"]["location"], method, "method={method}"); - assert!( - value["error"]["message"] - .as_str() - .unwrap() - .contains("params 不能为空"), - "method={method}" - ); - } - } - - #[test] - fn dispatch_patch_apply_writes_text_patch_target() { - let temp = tempfile::TempDir::new().unwrap(); - let source_path = temp.path().join("source.txt"); - let patch_path = temp.path().join("patch.json"); - let target_path = temp.path().join("target.txt"); - fs::write(&source_path, "先生、こんにちは").unwrap(); - let patch = bat_patch::text::diff("先生、こんにちは", "老師、你好"); - fs::write(&patch_path, serde_json::to_vec(&patch).unwrap()).unwrap(); - - let control = new_daemon_control(); - let envelope = dispatch_rpc_method( - &rpc_request( - "patch.apply", - Some(serde_json::json!({ - "kind": "text", - "source_path": source_path, - "patch_path": patch_path, - "target_path": target_path, - })), - ), - temp.path(), - &control, - &test_task_context(), - "req-test-patch-apply".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["command"], "patch.apply"); - assert_eq!( - fs::read_to_string(temp.path().join("target.txt")).unwrap(), - "老師、你好" - ); - } - - #[test] - fn dispatch_task_list_returns_empty_ok_envelope() { - let temp = tempfile::TempDir::new().unwrap(); - let control = new_daemon_control(); - let envelope = dispatch_rpc_method( - &rpc_request("task.list", None), - temp.path(), - &control, - &test_task_context(), - "req-test-3".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["status"], "ok"); - assert_eq!(value["data"]["tasks"], serde_json::json!([])); - } - - #[test] - fn dispatch_task_status_reports_task_not_found() { - let temp = tempfile::TempDir::new().unwrap(); - let control = new_daemon_control(); - let envelope = dispatch_rpc_method( - &rpc_request("task.status", Some(serde_json::json!({ "task_id": "abc" }))), - temp.path(), - &control, - &test_task_context(), - "req-test-4".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], false); - assert_eq!(value["error"]["code"], "BAT-ERR-700004"); - } - - #[test] - fn dispatch_daemon_doctor_returns_report() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("output"); - let base_config = OfficialUpdateConfig { - output_root, - ..Default::default() - }; - let (queue, _rx) = mpsc::channel::(); - let context = DaemonTaskContext { - registry: TaskRegistry::new(), - queue, - base_config, - restart_controller: test_restart_controller, - }; - - let envelope = dispatch_rpc_method( - &rpc_request("daemon.doctor", None), - &state_dir, - &new_daemon_control(), - &context, - "req-doctor-1".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["status"], "ok"); - assert_eq!(value["data"]["command"], "doctor"); - assert!(value["data"]["healthy"].is_boolean()); - let checks = value["data"]["checks"].as_array().unwrap(); - assert!(checks.iter().any(|check| check["name"] == "daemon_rpc")); - } - - #[test] - fn dispatch_resource_sync_enqueues_task() { - let temp = tempfile::TempDir::new().unwrap(); - let control = new_daemon_control(); - // 保留 rx 让 send 成功(不启动 worker,任务停留在 queued)。 - let (queue, rx) = mpsc::channel::(); - let context = DaemonTaskContext { - registry: TaskRegistry::new(), - queue, - base_config: OfficialUpdateConfig::default(), - restart_controller: test_restart_controller, - }; - - let envelope = dispatch_rpc_method( - &rpc_request("resource.sync", Some(serde_json::json!({ "force": true }))), - temp.path(), - &control, - &context, - "req-test-5".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["status"], "accepted"); - let task_id = value["data"]["task_id"].as_str().unwrap().to_string(); - assert_eq!(value["data"]["kind"], "resource.sync"); - - // 注册表里应有一条 queued 任务。 - let record = context.registry.get(&task_id).unwrap(); - assert_eq!(record.status, "queued"); - assert_eq!(record.kind, "resource.sync"); - - // 作业已入队,force 已透传到派生配置。 - let job = rx.try_recv().unwrap(); - assert_eq!(job.id, task_id); - assert!(job.config.force); - assert!(!job.config.dry_run); - - // task.status 能查到这条任务。 - let status_envelope = dispatch_rpc_method( - &rpc_request( - "task.status", - Some(serde_json::json!({ "task_id": task_id })), - ), - temp.path(), - &control, - &context, - "req-test-6".to_string(), - ); - let status_value = serde_json::to_value(&status_envelope).unwrap(); - assert_eq!(status_value["ok"], true); - assert_eq!(status_value["data"]["status"], "queued"); - } - - #[test] - fn dispatch_resource_repair_enqueues_repair_task() { - let temp = tempfile::TempDir::new().unwrap(); - let control = new_daemon_control(); - // 保留 rx 让 send 成功(不启动 worker,任务停留在 queued)。 - let (queue, rx) = mpsc::channel::(); - let base_config = OfficialUpdateConfig { - force: true, - dry_run: true, - audit_local: false, - repair: false, - ..Default::default() - }; - let context = DaemonTaskContext { - registry: TaskRegistry::new(), - queue, - base_config, - restart_controller: test_restart_controller, - }; - - let envelope = dispatch_rpc_method( - &rpc_request("resource.repair", None), - temp.path(), - &control, - &context, - "req-repair-1".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["status"], "accepted"); - assert_eq!(value["data"]["kind"], "resource.repair"); - let task_id = value["data"]["task_id"].as_str().unwrap(); - let record = context.registry.get(task_id).unwrap(); - assert_eq!(record.kind, "resource.repair"); - assert_eq!(record.status, "queued"); - - // repair 任务强制开启本地审计+修复,但不会继承 force/dry-run。 - let job = rx.try_recv().unwrap(); - assert_eq!(job.id, task_id); - assert!(!job.config.dry_run); - assert!(job.config.audit_local); - assert!(job.config.repair); - assert!(!job.config.force); - } - - #[test] - fn task_registry_create_update_and_prune() { - let registry = TaskRegistry::new(); - let id = registry.create(TaskKind::Verify); - assert_eq!(registry.get(&id).unwrap().status, "queued"); - registry.update(&id, |record| record.status = "running"); - assert_eq!(registry.get(&id).unwrap().status, "running"); - assert_eq!(registry.list().len(), 1); - - // 超过上限时裁剪最旧的已结束任务;未结束的保留。 - for _ in 0..MAX_RETAINED_TASKS { - let finished = registry.create(TaskKind::Sync); - registry.update(&finished, |record| record.status = "succeeded"); - } - assert!(registry.list().len() <= MAX_RETAINED_TASKS); - // 运行中的任务不被裁剪。 - assert_eq!(registry.get(&id).unwrap().status, "running"); - } - - #[test] - fn parses_explicit_watch_error_retry_interval() { - let options = - parse(&["bat", "--watch", "--interval", "1h", "--error-retry", "45s"]).unwrap(); - assert_eq!(options.interval, Duration::from_secs(3600)); - assert_eq!(options.error_retry_interval, Duration::from_secs(45)); - - let options = parse(&["bat", "--watch", "--error-retry-seconds", "30"]).unwrap(); - assert_eq!(options.error_retry_interval, Duration::from_secs(30)); - } - - #[test] - fn parses_progress_flags() { - let options = parse(&["bat"]).unwrap(); - assert!(options.progress); - assert!(options.banner); - - let options = parse(&["bat", "--no-progress"]).unwrap(); - assert!(!options.progress); - assert!(!options.banner); - - let options = parse(&["bat", "--no-progress", "--progress"]).unwrap(); - assert!(options.progress); - assert!(options.banner); - } - - #[test] - fn parses_banner_flags() { - let options = parse(&["bat", "--no-banner"]).unwrap(); - assert!(options.progress); - assert!(!options.banner); - - let options = parse(&["bat", "--no-progress", "--banner"]).unwrap(); - assert!(!options.progress); - assert!(options.banner); - } - - #[test] - fn startup_banner_contains_product_name() { - assert!(STARTUP_BANNER.contains("BlueArchiveToolkit")); - } - - #[test] - fn verification_summary_output_names_all_validation_types() { - let summary = OfficialVerificationSummary::new(7, 1, 2, 3); - let lines = verification_summary_lines(&summary); - - assert_eq!(lines.len(), 3); - assert!(lines[0].contains("官方 .hash 强校验")); - assert!(lines[0].contains("xxHash32")); - assert!(lines[1].contains("本地 BLAKE3 复用校验")); - assert!(lines[1].contains("需修复")); - assert!(lines[2].contains("ZIP 结构校验")); - assert!(lines[2].contains("central directory")); - - let json = serde_json::to_value(&summary).unwrap(); - assert_eq!(json["official_hash_verified_count"], 2); - assert_eq!(json["local_manifest_blake3_verified_count"], 7); - assert_eq!(json["local_manifest_repair_needed_count"], 1); - assert_eq!(json["zip_structure_verified_count"], 3); - } - - #[test] - fn daemon_rpc_download_progress_formats_as_human_text() { - let progress = serde_json::json!({ - "index": 2, - "total": 19, - "url": "https://prod-clientpatch.bluearchiveyostar.com/file.zip", - "status": "finished", - "bytes": 100, - "transferred_bytes": 100 - }); - - let formatted = format_daemon_download_progress_json(&progress).unwrap(); - - assert_eq!( - formatted, - "2/19 finished https://prod-clientpatch.bluearchiveyostar.com/file.zip" - ); - assert!(!formatted.contains('{')); - } - - #[test] - fn daemon_rpc_failed_download_progress_formats_as_human_text() { - let progress = serde_json::json!({ - "index": 3, - "total": 19, - "url": "https://prod-clientpatch.bluearchiveyostar.com/missing.zip", - "status": "failed", - "failure_kind": "http_404", - "failure_http_status": 404, - "failure_retryable": false, - "failure_attempts": 1, - "quarantined": true - }); - - let formatted = format_daemon_download_progress_json(&progress).unwrap(); - - assert_eq!( - formatted, - "3/19 failed kind=http_404 http=404 retryable=false attempts=1 quarantined=true https://prod-clientpatch.bluearchiveyostar.com/missing.zip" - ); - assert!(!formatted.contains('{')); - } - - #[test] - fn daemon_status_hides_failure_superseded_by_in_progress_version() { - let version = OfficialVersionRecord { - id: "1.70.0-current-attempt".to_string(), - app_version: "1.70.0".to_string(), - bundle_version: Some("s8tloc7lo3".to_string()), - addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_token" - .to_string(), - resource_root: PathBuf::from("/tmp/bat/.staging/current"), - snapshot_path: PathBuf::from("/tmp/bat/.staging/current/official-sync-snapshot.json"), - staging_path: Some(PathBuf::from("/tmp/bat/.staging/current")), - version_path: None, - started_unix_seconds: Some(100), - completed_unix_seconds: None, - }; - let older_failure = OfficialFailedVersionRecord { - version: OfficialVersionRecord { - id: "1.70.0-older-failure".to_string(), - resource_root: PathBuf::from("/tmp/bat/.staging/older"), - snapshot_path: PathBuf::from("/tmp/bat/.staging/older/official-sync-snapshot.json"), - staging_path: Some(PathBuf::from("/tmp/bat/.staging/older")), - started_unix_seconds: Some(90), - ..version.clone() - }, - error: "官方 hash 校验失败 fixture".to_string(), - failed_unix_seconds: 95, - }; - let state = OfficialVersionState { - in_progress_version: Some(version), - failed_versions: vec![older_failure], - updated_unix_seconds: 100, - ..OfficialVersionState::default() - }; - - assert!(visible_historical_failed_versions(&state).is_empty()); - } - - #[test] - fn daemon_socket_path_uses_state_dir() { - assert_eq!( - daemon_socket_path(Path::new("/tmp/custom-bat-pid")), - PathBuf::from("/tmp/custom-bat-pid/bat.sock") - ); - } - - #[cfg(unix)] - #[test] - fn active_official_resource_root_uses_current_symlink() { - use std::os::unix::fs::symlink; - - let temp = tempfile::TempDir::new().unwrap(); - let output = temp.path().join("resources"); - let version = output.join("versions").join("v1"); - fs::create_dir_all(&version).unwrap(); - symlink(Path::new("versions").join("v1"), output.join("current")).unwrap(); - - assert_eq!( - active_official_resource_root(&output).unwrap(), - lexical_absolute(&version).unwrap() - ); - } - - #[cfg(unix)] - #[test] - fn daemon_rpc_rejects_socket_symlink() { - use std::os::unix::fs::symlink; - - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - fs::create_dir_all(&state_dir).unwrap(); - let outside_socket = temp.path().join("outside.sock"); - let socket_path = daemon_socket_path(&state_dir); - symlink(&outside_socket, &socket_path).unwrap(); - - assert!(daemon_socket_path_exists(&socket_path).unwrap()); - assert!(!daemon_rpc_available(&state_dir)); - let error = daemon_rpc_call(&state_dir, RPC_METHOD_STATUS, None).unwrap_err(); - assert!(error.to_string().contains("symlink")); - } - - #[test] - fn rpc_tail_param_defaults_and_rejects_zero() { - assert_eq!(rpc_tail_param(None, 200).unwrap(), 200); - assert_eq!( - rpc_tail_param(Some(&serde_json::json!({ "tail": 25 })), 200).unwrap(), - 25 - ); - let error = rpc_tail_param(Some(&serde_json::json!({ "tail": 0 })), 200).unwrap_err(); - assert!(error.to_string().contains("大于 0")); - } - - #[test] - fn json_rpc_request_and_response_are_line_protocol_friendly() { - let request: JsonRpcRequest = serde_json::from_value(serde_json::json!({ - "jsonrpc": "2.0", - "id": 7, - "method": "bat.refresh", - "params": { "force": true } - })) - .unwrap(); - // bat.* 是兼容别名,解析为规范的 daemon.* 方法名。 - assert_eq!(request.method, "bat.refresh"); - assert_eq!(canonical_rpc_method(&request.method), RPC_METHOD_REFRESH); - assert_eq!(rpc_bool_param(request.params.as_ref(), "force"), Some(true)); - - let response = json_rpc_result( - Some(serde_json::json!(7)), - serde_json::json!({ "ok": true }), - ); - let serialized = serde_json::to_string(&response).unwrap(); - assert!(serialized.contains("\"jsonrpc\":\"2.0\"")); - assert!(serialized.contains("\"ok\":true")); - assert!(!serialized.contains('\n')); - } - - #[test] - fn daemon_control_wake_handles_refresh_reload_and_stop() { - let control = new_daemon_control(); - daemon_control_request_refresh(&control, true); - assert_eq!( - wait_for_daemon_wake(Some(&control), Duration::from_millis(1)), - DaemonWake::Refresh { force: true } - ); - - daemon_control_request_reload(&control); - assert_eq!( - wait_for_daemon_wake(Some(&control), Duration::from_millis(1)), - DaemonWake::Reload - ); - - daemon_control_request_refresh(&control, false); - daemon_control_mark_stop_requested(&control); - daemon_control_notify_all(&control); - assert_eq!( - wait_for_daemon_wake(Some(&control), Duration::from_millis(1)), - DaemonWake::Stop - ); - } - - #[cfg(unix)] - #[test] - fn shutdown_signals_wake_watch_without_mutating_daemon_control() { - clear_shutdown_signal_request(); - let control = new_daemon_control(); - - handle_shutdown_signal(libc::SIGINT); - assert!(watch_stop_requested(Some(&control))); - assert_eq!( - wait_for_daemon_wake(Some(&control), Duration::from_secs(60)), - DaemonWake::Stop - ); - - clear_shutdown_signal_request(); - handle_shutdown_signal(libc::SIGTERM); - assert!(watch_stop_requested(Some(&control))); - assert_eq!( - wait_for_daemon_wake(Some(&control), Duration::from_secs(60)), - DaemonWake::Stop - ); - - clear_shutdown_signal_request(); - assert!(!watch_stop_requested(Some(&control))); - } - - #[test] - fn daemon_control_lock_rejects_active_owner() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - - let first = DaemonControlLock::acquire(&state_dir).unwrap(); - let second = DaemonControlLock::acquire(&state_dir).unwrap_err(); - assert!(second.to_string().contains("locked")); - drop(first); - assert!(DaemonControlLock::acquire(&state_dir).is_ok()); - } - - #[test] - fn daemon_control_lock_recovers_stale_and_corrupt_files() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - fs::create_dir_all(&state_dir).unwrap(); - let lock_path = daemon_control_lock_path(&state_dir); - - fs::write(&lock_path, "999999999").unwrap(); - let lock = DaemonControlLock::acquire(&state_dir).unwrap(); - drop(lock); - assert!(!lock_path.exists()); - - fs::write(&lock_path, "not-a-pid").unwrap(); - let lock = DaemonControlLock::acquire(&state_dir).unwrap(); - assert_eq!( - fs::read_to_string(&lock_path).unwrap(), - std::process::id().to_string() - ); - drop(lock); - assert!(!lock_path.exists()); - } - - #[cfg(unix)] - #[test] - fn daemon_control_lock_file_is_private() { - use std::os::unix::fs::PermissionsExt; - - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let lock_path = daemon_control_lock_path(&state_dir); - let lock = DaemonControlLock::acquire(&state_dir).unwrap(); - - let mode = fs::metadata(&lock_path).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600); - drop(lock); - } - - #[cfg(unix)] - #[test] - fn daemon_status_file_is_private_and_rejects_symlink() { - use std::os::unix::fs::{symlink, PermissionsExt}; - - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("resources"); - fs::create_dir_all(&state_dir).unwrap(); - fs::create_dir_all(&output_root).unwrap(); - let status_path = daemon_status_path(&state_dir); - let status = test_daemon_status_file(&state_dir, &output_root); - - write_daemon_status_file(&status_path, &status).unwrap(); - let mode = fs::metadata(&status_path).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600); - - fs::remove_file(&status_path).unwrap(); - let outside = temp.path().join("outside-status.json"); - fs::write(&outside, b"outside").unwrap(); - symlink(&outside, &status_path).unwrap(); - - let error = write_daemon_status_file(&status_path, &status).unwrap_err(); - assert!(error.to_string().contains("symlink")); - assert_eq!(fs::read(&outside).unwrap(), b"outside"); - } - - #[test] - fn daemon_status_tracks_progress_success_and_next_check() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("resources"); - fs::create_dir_all(&state_dir).unwrap(); - fs::create_dir_all(&output_root).unwrap(); - write_daemon_status_file( - &daemon_status_path(&state_dir), - &test_daemon_status_file(&state_dir, &output_root), - ) - .unwrap(); - - let mut event = OfficialUpdateProgress::new("download", "(2/19) 开始 fixture"); - event.download_index = Some(2); - event.download_total = Some(19); - event.download_url = Some("https://prod-clientpatch.bluearchiveyostar.com/file.zip".into()); - update_daemon_progress(&state_dir, &event).unwrap(); - - let report = build_daemon_status_report(&state_dir).unwrap(); - assert_eq!(report.current_stage.as_deref(), Some("download")); - assert_eq!(report.download_progress.as_ref().unwrap().index, 2); - assert_eq!(report.download_progress.as_ref().unwrap().total, 19); - - let next_check = unix_seconds_after(Duration::from_secs(30)); - update_daemon_status( - &state_dir, - DaemonStatusUpdate { - state: "sleeping", - last_update_status: Some("downloaded".to_string()), - last_error: None, - next_retry_seconds: Some(30), - last_success_unix_seconds: Some(123456), - next_check_unix_seconds: Some(next_check), - pending_scheduled_force: false, - next_forced_refresh_at: SystemTime::now() + Duration::from_secs(3600), - }, - ) - .unwrap(); - let report = build_daemon_status_report(&state_dir).unwrap(); - assert_eq!(report.last_success_unix_seconds, Some(123456)); - assert_eq!(report.next_check_unix_seconds, Some(next_check)); - assert!(report.current_stage.is_none()); - assert!(report.download_progress.is_none()); - } - - #[test] - fn structured_logger_writes_jsonl_and_rotates() { - let temp = tempfile::TempDir::new().unwrap(); - let path = temp.path().join("bat-events.jsonl"); - let mut logger = RotatingStructuredLogger::new(path.clone(), 180, 2); - let mut event = OfficialUpdateProgress::new("download", "下载进度 fixture"); - event.download_index = Some(1); - event.download_total = Some(2); - event.download_url = Some("https://prod-clientpatch.bluearchiveyostar.com/a.zip".into()); - event.download_verification = Some(OfficialResourceVerification { - expected_bytes: Some(123), - actual_bytes: 123, - expected_blake3: Some("expected-blake3".to_string()), - actual_blake3: "expected-blake3".to_string(), - zip_checked: true, - zip_structure_verified: true, - }); - event.official_hash_verification = Some(OfficialResourceHashVerification { - data_url: "https://prod-clientpatch.bluearchiveyostar.com/a.bytes".to_string(), - hash_url: "https://prod-clientpatch.bluearchiveyostar.com/a.hash".to_string(), - algorithm: bat_infrastructure::OfficialResourceHashAlgorithm::XxHash32Decimal, - expected: "2044170421".to_string(), - actual: "2044170421".to_string(), - }); - - for _ in 0..8 { - logger - .write_event(Duration::from_millis(42), &event) - .unwrap(); - } - - assert!(path.exists()); - assert!(rotated_structured_log_path(&path, 1).exists()); - let line = fs::read_to_string(&path) - .unwrap() - .lines() - .next() - .unwrap() - .to_string(); - let value: serde_json::Value = serde_json::from_str(&line).unwrap(); - assert_eq!(value["stage"], "download"); - assert_eq!(value["download"]["index"], 1); - assert_eq!(value["download"]["total"], 2); - assert_eq!(value["download"]["verification"]["actual_bytes"], 123); - assert_eq!( - value["download"]["verification"]["zip_structure_verified"], - true - ); - assert_eq!( - value["download"]["official_hash"]["algorithm"], - "xxhash32_decimal" - ); - assert_eq!(value["download"]["official_hash"]["expected"], "2044170421"); - } - - #[cfg(unix)] - #[test] - fn read_pid_file_rejects_symlink() { - use std::os::unix::fs::symlink; - - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - fs::create_dir_all(&state_dir).unwrap(); - let outside = temp.path().join("outside.pid"); - fs::write(&outside, b"123").unwrap(); - let pid_path = daemon_pid_path(&state_dir); - symlink(&outside, &pid_path).unwrap(); - - let error = read_pid_file(&pid_path).unwrap_err(); - assert!(error.to_string().contains("symlink")); - } - - #[test] - fn live_daemon_resource_conflict_detects_same_output() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("resources"); - fs::create_dir_all(&output_root).unwrap(); - write_daemon_status_file( - &daemon_status_path(&state_dir), - &test_daemon_status_file(&state_dir, &output_root), - ) - .unwrap(); - fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap(); - - let conflict = live_daemon_resource_conflict(&state_dir, &output_root) - .unwrap() - .unwrap(); - assert_eq!(conflict.pid, Some(std::process::id())); - assert_eq!( - conflict.target_root, - normalized_abs_path(&output_root).unwrap() - ); - } - - #[test] - fn live_daemon_resource_conflict_allows_different_output() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let daemon_output = temp.path().join("daemon-resources"); - let manual_output = temp.path().join("manual-resources"); - fs::create_dir_all(&daemon_output).unwrap(); - fs::create_dir_all(&manual_output).unwrap(); - write_daemon_status_file( - &daemon_status_path(&state_dir), - &test_daemon_status_file(&state_dir, &daemon_output), - ) - .unwrap(); - fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap(); - - assert!(live_daemon_resource_conflict(&state_dir, &manual_output) - .unwrap() - .is_none()); - } - - #[test] - fn direct_write_rejects_live_daemon_for_same_output() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("resources"); - fs::create_dir_all(&output_root).unwrap(); - write_daemon_status_file( - &daemon_status_path(&state_dir), - &test_daemon_status_file(&state_dir, &output_root), - ) - .unwrap(); - fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap(); - let options = CliOptions { - state_dir, - config: OfficialUpdateConfig { - output_root, - ..OfficialUpdateConfig::default() - }, - ..CliOptions::default() - }; - - let error = assert_no_live_daemon_output_conflict(&options, "repair").unwrap_err(); - assert!(error.to_string().contains("同一资源目录")); - assert!(error.to_string().contains("locked")); - } - - #[test] - fn clean_stable_removes_corrupt_pid_and_lock_files() { - let temp = tempfile::TempDir::new().unwrap(); - let output_root = temp.path().join("resources"); - let state_dir = temp.path().join("state"); - fs::create_dir_all(&output_root).unwrap(); - fs::create_dir_all(&state_dir).unwrap(); - let resource_lock = output_root.join(".official-sync.lock"); - let control_lock = daemon_control_lock_path(&state_dir); - let pid_path = daemon_pid_path(&state_dir); - fs::write(&resource_lock, "not-a-pid").unwrap(); - fs::write(&control_lock, "not-a-pid").unwrap(); - fs::write(&pid_path, "not-a-pid").unwrap(); - - let options = CliOptions { - command: CliCommand::CleanStable, - output_format: OutputFormat::Json, - state_dir, - config: OfficialUpdateConfig { - output_root, - ..OfficialUpdateConfig::default() - }, - ..CliOptions::default() - }; - run_clean_stable_command(&options).unwrap(); - - assert!(!resource_lock.exists()); - assert!(!control_lock.exists()); - assert!(!pid_path.exists()); - } - - #[test] - fn rpc_stop_wait_reports_already_stopped_without_signal() { - assert!(!wait_for_rpc_stop_or_terminate(0, Duration::from_millis(1)).unwrap()); - } - - #[test] - fn sync_command_rpc_selection_only_for_default_daemon_shape() { - let options = parse(&["bat", "refresh"]).unwrap(); - assert!(refresh_should_use_daemon_rpc(&options, "refresh")); - assert_eq!( - sync_command_rpc_method(&options, "refresh"), - Some(RPC_METHOD_REFRESH) - ); - - let options = parse(&["bat", "refresh", "--force"]).unwrap(); - assert!(refresh_should_use_daemon_rpc(&options, "refresh")); - assert_eq!( - sync_command_rpc_method(&options, "refresh"), - Some(RPC_METHOD_REFRESH) - ); - - let options = parse(&["bat", "refresh", "--output", "/tmp/other"]).unwrap(); - assert!(!refresh_should_use_daemon_rpc(&options, "refresh")); - assert_eq!(sync_command_rpc_method(&options, "refresh"), None); - - let options = parse(&["bat", "refresh", "--server-info-file", "ProdNotice.json"]).unwrap(); - assert!(!refresh_should_use_daemon_rpc(&options, "refresh")); - assert_eq!(sync_command_rpc_method(&options, "refresh"), None); - - let options = parse(&["bat", "refresh", "--import-repository"]).unwrap(); - assert_eq!(sync_command_rpc_method(&options, "refresh"), None); - - let options = parse(&[ - "bat", - "refresh", - "--import-resource-db", - "/tmp/resources.sqlite", - ]) - .unwrap(); - assert_eq!(sync_command_rpc_method(&options, "refresh"), None); - - let options = parse(&["bat", "repair"]).unwrap(); - assert!(!refresh_should_use_daemon_rpc(&options, "repair")); - assert_eq!( - sync_command_rpc_method(&options, "repair"), - Some(RPC_METHOD_RESOURCE_REPAIR) - ); - - let options = parse(&["bat", "repair", "--output", "/tmp/other"]).unwrap(); - assert_eq!(sync_command_rpc_method(&options, "repair"), None); - - let options = parse(&["bat", "repair", "--force"]).unwrap(); - assert_eq!(sync_command_rpc_method(&options, "repair"), None); - } - - #[test] - fn repair_command_uses_rpc_when_default_daemon_shape() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - fs::create_dir_all(&state_dir).unwrap(); - let seen = Arc::new(Mutex::new(Vec::<(String, Option)>::new())); - let seen_calls = Arc::clone(&seen); - - let options = parse(&[ - "bat", - "repair", - "--json", - "--state-dir", - state_dir.to_str().unwrap(), - ]) - .unwrap(); - run_sync_command_with_rpc( - &options, - "repair", - |_| true, - move |_state_dir, method, params| { - seen_calls - .lock() - .unwrap() - .push((method.to_string(), params.clone())); - Ok(serde_json::json!({ - "task_id": "task-fake-repair", - "kind": "resource.repair", - })) - }, - ) - .unwrap(); - let seen = seen.lock().unwrap(); - assert_eq!(seen.len(), 1); - assert_eq!(seen[0].0, RPC_METHOD_RESOURCE_REPAIR); - assert_eq!(seen[0].1, None); - } - - #[test] - fn readonly_resource_index_command_uses_rpc_with_filters() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - fs::create_dir_all(&state_dir).unwrap(); - let seen = Arc::new(Mutex::new(Vec::<(String, Option)>::new())); - let seen_calls = Arc::clone(&seen); - let options = parse(&[ - "bat", - "resource-index", - "--json", - "--state-dir", - state_dir.to_str().unwrap(), - "--resource-type", - "asset_bundle", - "--release-id", - "v-current", - "--platform", - "windows", - "--destination", - "Bundles/story.bundle", - "--bundle-path", - "Bundles/story.bundle", - "--archive-entry", - "story/TextAsset", - "--parse-status", - "parsed", - "--format", - "json", - "--offset", - "2", - "--limit", - "3", - ]) - .unwrap(); - - run_readonly_query_command_with_rpc( - &options, - |_| true, - move |_state_dir, method, params| { - seen_calls - .lock() - .unwrap() - .push((method.to_string(), params.clone())); - Ok(serde_json::json!({ "available": true, "entries": [] })) - }, - ) - .unwrap(); - - let seen = seen.lock().unwrap(); - assert_eq!(seen.len(), 1); - assert_eq!(seen[0].0, RPC_METHOD_RESOURCE_INDEX); - let params = seen[0].1.as_ref().unwrap(); - assert_eq!(params["resource_type"], "asset_bundle"); - assert_eq!(params["official_release_id"], "v-current"); - assert_eq!(params["platform"], "windows"); - assert_eq!(params["destination"], "Bundles/story.bundle"); - assert_eq!(params["bundle_path"], "Bundles/story.bundle"); - assert_eq!(params["archive_entry"], "story/TextAsset"); - assert_eq!(params["parse_status"], "parsed"); - assert_eq!(params["text_unit_format"], "json"); - assert_eq!(params["offset"], 2); - assert_eq!(params["limit"], 3); - } - - #[test] - fn readonly_translation_tasks_command_uses_rpc_with_filters() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - fs::create_dir_all(&state_dir).unwrap(); - let seen = Arc::new(Mutex::new(Vec::<(String, Option)>::new())); - let seen_calls = Arc::clone(&seen); - let options = parse(&[ - "bat", - "translation-tasks", - "--json", - "--state-dir", - state_dir.to_str().unwrap(), - "--task-id", - "textunit/v-current/Bundles/story.bundle", - "--release-id", - "v-current", - "--destination", - "Bundles/story.bundle", - "--archive-entry", - "story/TextAsset", - "--task-status", - "skipped_parse_failed", - "--parse-status", - "failed", - "--format", - "json", - "--has-reason", - "--offset", - "4", - "--limit", - "5", - ]) - .unwrap(); - - run_readonly_query_command_with_rpc( - &options, - |_| true, - move |_state_dir, method, params| { - seen_calls - .lock() - .unwrap() - .push((method.to_string(), params.clone())); - Ok(serde_json::json!({ "available": true, "entries": [] })) - }, - ) - .unwrap(); - - let seen = seen.lock().unwrap(); - assert_eq!(seen.len(), 1); - assert_eq!(seen[0].0, RPC_METHOD_TRANSLATION_TASKS); - let params = seen[0].1.as_ref().unwrap(); - assert_eq!(params["task_id"], "textunit/v-current/Bundles/story.bundle"); - assert_eq!(params["official_release_id"], "v-current"); - assert_eq!(params["destination"], "Bundles/story.bundle"); - assert_eq!(params["archive_entry"], "story/TextAsset"); - assert_eq!(params["status"], "skipped_parse_failed"); - assert_eq!(params["parse_status"], "failed"); - assert_eq!(params["text_unit_format"], "json"); - assert_eq!(params["has_reason"], true); - assert_eq!(params["offset"], 4); - assert_eq!(params["limit"], 5); - } - - #[test] - fn explicit_no_quiet_up_to_date_overrides_watch_default() { - let options = parse(&[ - "bat", - "--watch", - "--no-quiet-up-to-date", - "--interval", - "1h", - ]) - .unwrap(); - - assert!(options.watch); - assert!(!options.quiet_up_to_date); - assert!(options.quiet_up_to_date_explicit); - } - - #[test] - fn rejects_watch_dry_run_and_zero_interval() { - let error = parse(&["bat", "--watch", "--dry-run"]).unwrap_err(); - assert!(error.to_string().contains("不能和 --dry-run")); - - let error = parse(&["bat", "--daemon", "--watch"]).unwrap_err(); - assert!(error.to_string().contains("不要同时传 --watch")); - - let error = parse(&["bat", "status", "--watch"]).unwrap_err(); - assert!(error.to_string().contains("status/stop")); - - let error = parse(&["bat", "status", "--output", "/tmp/resources"]).unwrap_err(); - assert!(error.to_string().contains("--state-dir")); - - let error = parse(&["bat", "--watch", "--interval-seconds", "0"]).unwrap_err(); - assert!(error.to_string().contains("必须大于 0")); - - let error = parse(&["bat", "--watch", "--error-retry-seconds", "0"]).unwrap_err(); - assert!(error.to_string().contains("失败重试间隔")); - } - - #[test] - fn parses_duration_units() { - assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600)); - assert_eq!(parse_duration("60m").unwrap(), Duration::from_secs(3600)); - assert_eq!(parse_duration("90s").unwrap(), Duration::from_secs(90)); - assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250)); - assert_eq!(parse_duration("3600").unwrap(), Duration::from_secs(3600)); - } - - #[test] - fn formats_duration_args_for_child_process() { - assert_eq!(format_duration_arg(Duration::from_secs(3600)), "1h"); - assert_eq!(format_duration_arg(Duration::from_secs(90 * 60)), "90m"); - assert_eq!(format_duration_arg(Duration::from_secs(45)), "45s"); - assert_eq!(format_duration_arg(Duration::from_millis(250)), "250ms"); - } - - #[test] - fn scheduled_forced_refresh_uses_beijing_local_times() { - let day = 20_000; - - assert_eq!( - next_forced_refresh_at_or_after(beijing_time(day, 2, 30, 0)), - beijing_time(day, 3, 0, 0) - ); - assert_eq!( - next_forced_refresh_at_or_after(beijing_time(day, 3, 0, 0)), - beijing_time(day, 3, 0, 0) - ); - assert_eq!( - next_forced_refresh_after(beijing_time(day, 3, 0, 0)), - beijing_time(day, 16, 0, 0) - ); - assert_eq!( - next_forced_refresh_after(beijing_time(day, 18, 0, 0)), - beijing_time(day + 1, 3, 0, 0) - ); - assert_eq!( - duration_until( - next_forced_refresh_at_or_after(beijing_time(day, 17, 45, 0)), - beijing_time(day, 17, 45, 0) - ), - Duration::from_secs(15 * 60) - ); - } - - #[test] - fn formats_progress_durations() { - assert_eq!(format_duration(Duration::from_millis(7)), "00:00.007"); - assert_eq!(format_duration(Duration::from_millis(65_432)), "01:05.432"); - } - - #[test] - fn quiet_up_to_date_suppresses_only_clean_reports() { - assert!(!should_print_status(OfficialUpdateStatus::UpToDate, true)); - assert!(should_print_status(OfficialUpdateStatus::Downloaded, true)); - assert!(should_print_status( - OfficialUpdateStatus::WouldDownload, - true - )); - assert!(should_print_status(OfficialUpdateStatus::UpToDate, false)); - } - - #[test] - fn status_string_is_stable() { - assert_eq!(OfficialUpdateStatus::UpToDate.as_str(), "up_to_date"); - assert_eq!( - OfficialUpdateStatus::WouldDownload.as_str(), - "would_download" - ); - assert_eq!(OfficialUpdateStatus::Downloaded.as_str(), "downloaded"); - } - - #[test] - fn redacts_proxy_credentials_in_surfaced_command() { - let command = vec![ - "/opt/bat".to_string(), - "--auto-discover".to_string(), - "--proxy".to_string(), - "http://user:secret@127.0.0.1:7890".to_string(), - "--output".to_string(), - "/var/lib/bat".to_string(), - ]; - - let redacted = redact_command_proxy_credentials(command); - - assert_eq!(redacted[3], "http://@127.0.0.1:7890"); - assert!(!redacted.join(" ").contains("secret")); - // 非代理参数保持原样。 - assert_eq!(redacted[1], "--auto-discover"); - assert_eq!(redacted[5], "/var/lib/bat"); - } - - #[test] - fn redacts_schemeless_proxy_credentials_in_surfaced_command() { - let command = vec![ - "/opt/bat".to_string(), - "--proxy".to_string(), - "user:secret@127.0.0.1:7890".to_string(), - ]; - - let redacted = redact_command_proxy_credentials(command); - - assert_eq!(redacted[2], "@127.0.0.1:7890"); - assert!(!redacted.join(" ").contains("secret")); - } - - #[test] - fn leaves_command_without_proxy_untouched() { - let command = vec![ - "/opt/bat".to_string(), - "--auto-discover".to_string(), - "--proxy".to_string(), - "auto".to_string(), - ]; - - let redacted = redact_command_proxy_credentials(command.clone()); - - assert_eq!(redacted, command); - } - - fn beijing_time(day: u64, hour: u64, minute: u64, second: u64) -> SystemTime { - system_time_from_beijing_local_seconds( - day.saturating_mul(SECONDS_PER_DAY) - .saturating_add(hour.saturating_mul(60 * 60)) - .saturating_add(minute.saturating_mul(60)) - .saturating_add(second), - ) - } - - fn test_daemon_status_file(state_dir: &Path, output_root: &Path) -> DaemonStatusFile { - test_daemon_status_file_with_localized( - state_dir, - output_root, - &output_root.with_file_name("localized"), - ) - } - - fn test_daemon_status_file_with_localized( - state_dir: &Path, - output_root: &Path, - localized_output_root: &Path, - ) -> DaemonStatusFile { - DaemonStatusFile { - version: DAEMON_STATUS_VERSION, - pid: std::process::id(), - state: "sleeping".to_string(), - resource_output_root: output_root.to_path_buf(), - localized_output_root: Some(localized_output_root.to_path_buf()), - state_dir: state_dir.to_path_buf(), - log_path: daemon_log_path(state_dir), - structured_log_path: Some(daemon_structured_log_path(state_dir)), - started_unix_seconds: unix_seconds_now(), - updated_unix_seconds: unix_seconds_now(), - last_success_unix_seconds: Some(unix_seconds_now()), - next_check_unix_seconds: Some(unix_seconds_after(Duration::from_secs(60))), - last_update_status: Some("up_to_date".to_string()), - last_error: None, - next_retry_seconds: Some(60), - current_stage: None, - status_code: Some(ReleaseFlowStatusCode::OfficialUpToDate.as_str().to_string()), - current_message: None, - download_progress: None, - pending_scheduled_force: false, - next_forced_refresh_unix_seconds: None, - command: vec!["bat".to_string(), "--daemon-child".to_string()], - } - } - - /// 写入 catalog 只读查询所需的完整 fixture:daemon 状态文件、版本目录内的 - /// snapshot、版本状态文件。返回当前版本目录路径。 - fn write_catalog_fixture( - state_dir: &Path, - output_root: &Path, - current_bundle: &str, - previous_bundle: Option<&str>, - ) -> PathBuf { - fs::create_dir_all(state_dir).unwrap(); - fs::create_dir_all(output_root).unwrap(); - write_daemon_status_file( - &daemon_status_path(state_dir), - &test_daemon_status_file(state_dir, output_root), - ) - .unwrap(); - - let make_version = |id: &str, bundle: &str| -> OfficialVersionRecord { - let version_dir = output_root.join("versions").join(id); - fs::create_dir_all(&version_dir).unwrap(); - let snapshot_path = version_dir.join("official-sync-snapshot.json"); - let snapshot = OfficialUpdateSnapshot { - snapshot_version: - bat_infrastructure::official_update::OFFICIAL_UPDATE_SNAPSHOT_VERSION, - connection_group_name: "Prod-Audit".to_string(), - app_version: "1.70.0".to_string(), - bundle_version: Some(bundle.to_string()), - addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture" - .to_string(), - endpoints: Vec::new(), - endpoint_markers: Vec::new(), - launcher_metadata: None, - game_main_config_bootstrap: Some(bat_infrastructure::GameMainConfigSnapshot { - server_info_data_url: Some( - "https://prod-serverinfo.bluearchiveyostar.com/server-info.json" - .to_string(), - ), - default_connection_group: Some("Prod-Audit".to_string()), - }), - }; - bat_infrastructure::write_snapshot(&snapshot_path, &snapshot).unwrap(); - OfficialVersionRecord { - id: id.to_string(), - app_version: "1.70.0".to_string(), - bundle_version: Some(bundle.to_string()), - addressables_root: snapshot.addressables_root.clone(), - resource_root: version_dir.clone(), - snapshot_path, - staging_path: None, - version_path: Some(version_dir.clone()), - started_unix_seconds: None, - completed_unix_seconds: Some(unix_seconds_now()), - } - }; - - let current = make_version("v-current", current_bundle); - let current_dir = current.resource_root.clone(); - let state = OfficialVersionState { - current_completed_version: Some(current), - previous_available_version: previous_bundle - .map(|bundle| make_version("v-previous", bundle)), - ..OfficialVersionState::default() - }; - bat_infrastructure::write_version_state( - &output_root.join("official-version-state.json"), - &state, - ) - .unwrap(); - current_dir - } - - #[test] - fn dispatch_resource_state_reports_status_code_for_bat_api() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("output"); - write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); - - let envelope = dispatch_rpc_method( - &rpc_request("resource.state", None), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-state-1".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["status"], "up_to_date"); - assert_eq!(value["data"]["status_code"], "official.up_to_date"); - assert_eq!(value["data"]["status_phase"], "official_sync"); - assert_eq!(value["data"]["status_terminal"], true); - assert_eq!(value["data"]["last_update_status"], "up_to_date"); - } - - #[test] - fn dispatch_catalog_status_unavailable_without_state() { - let temp = tempfile::TempDir::new().unwrap(); - let envelope = dispatch_rpc_method( - &rpc_request("catalog.status", None), - temp.path(), - &new_daemon_control(), - &test_task_context(), - "req-cat-0".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], false); - assert_eq!(value["data"]["status"], "unavailable"); - assert_eq!(value["data"]["status_code"], "official.unavailable"); - assert_eq!( - value["data"]["distribution_status_code"], - "distribution.blocked" - ); - } - - #[test] - fn dispatch_catalog_status_reads_current_snapshot() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("output"); - write_catalog_fixture(&state_dir, &output_root, "bundle-b2", Some("bundle-b1")); - - let envelope = dispatch_rpc_method( - &rpc_request("catalog.status", None), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-cat-1".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], true); - assert_eq!(value["data"]["bundle_version"], "bundle-b2"); - assert_eq!(value["data"]["status"], "published"); - assert_eq!(value["data"]["status_code"], "official.published"); - assert_eq!( - value["data"]["distribution_status_code"], - "distribution.ready" - ); - assert_eq!(value["data"]["version"]["id"], "v-current"); - assert_eq!(value["data"]["endpoint_count"], 0); - assert_eq!(value["data"]["connection_group_name"], "Prod-Audit"); - assert_eq!( - value["data"]["game_main_config_bootstrap"]["default_connection_group"], - "Prod-Audit" - ); - assert!(!value["data"] - .as_object() - .unwrap() - .contains_key("game_main_config")); - } - - #[test] - fn dispatch_catalog_versions_lists_history() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("output"); - write_catalog_fixture(&state_dir, &output_root, "bundle-b2", Some("bundle-b1")); - - let envelope = dispatch_rpc_method( - &rpc_request("catalog.versions", None), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-cat-2".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], true); - assert_eq!(value["data"]["current"]["id"], "v-current"); - assert_eq!(value["data"]["previous"]["id"], "v-previous"); - assert_eq!(value["data"]["in_progress"], serde_json::Value::Null); - assert!(value["data"]["failed"].as_array().unwrap().is_empty()); - } - - #[test] - fn dispatch_catalog_diff_reports_bundle_change() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("output"); - write_catalog_fixture(&state_dir, &output_root, "bundle-b2", Some("bundle-b1")); - - let envelope = dispatch_rpc_method( - &rpc_request("catalog.diff", None), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-cat-3".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], true); - assert_eq!(value["data"]["status"], "completed"); - assert_eq!(value["data"]["status_code"], "parse.completed"); - assert_eq!( - value["data"]["translation_status_code"], - "translation.queued_offline" - ); - assert_eq!(value["data"]["current_version_id"], "v-current"); - assert_eq!(value["data"]["previous_version_id"], "v-previous"); - assert_eq!(value["data"]["previous_snapshot_missing"], false); - assert_eq!(value["data"]["base_delta"]["is_initial"], false); - assert_eq!(value["data"]["base_delta"]["bundle_version_changed"], true); - assert_eq!( - value["data"]["base_delta"]["addressables_root_changed"], - false - ); - } - - #[test] - fn dispatch_catalog_diff_initial_without_previous() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("output"); - write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None); - - let envelope = dispatch_rpc_method( - &rpc_request("catalog.diff", None), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-cat-4".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], true); - assert_eq!( - value["data"]["previous_version_id"], - serde_json::Value::Null - ); - assert_eq!(value["data"]["base_delta"]["is_initial"], true); - } - - #[test] - fn dispatch_catalog_refresh_enqueues_task() { - let temp = tempfile::TempDir::new().unwrap(); - // 保留 rx 让 send 成功(不启动 worker,任务停留在 queued)。 - let (queue, rx) = mpsc::channel::(); - let tasks = DaemonTaskContext { - registry: TaskRegistry::new(), - queue, - base_config: OfficialUpdateConfig::default(), - restart_controller: test_restart_controller, - }; - let envelope = dispatch_rpc_method( - &rpc_request("catalog.refresh", None), - temp.path(), - &new_daemon_control(), - &tasks, - "req-cat-5".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["status"], "accepted"); - assert_eq!(value["data"]["kind"], "catalog.refresh"); - let task_id = value["data"]["task_id"].as_str().unwrap(); - let record = tasks.registry.get(task_id).unwrap(); - assert_eq!(record.kind, "catalog.refresh"); - assert_eq!(record.status, "queued"); - - // 作业配置是 dry-run 计划检查,不下载不审计。 - let job = rx.try_recv().unwrap(); - assert_eq!(job.id, task_id); - assert!(job.config.dry_run); - assert!(job.config.plan); - assert!(!job.config.audit_local); - } - - #[test] - fn catalog_refresh_config_is_dry_run_plan_only() { - let base = OfficialUpdateConfig::default(); - let config = TaskKind::Refresh.build_config(&base, false); - assert!(config.dry_run); - assert!(config.plan); - assert!(!config.audit_local); - assert!(!config.repair); - assert!(!config.force); - let forced = TaskKind::Refresh.build_config(&base, true); - assert!(forced.force); - } - - #[test] - fn dispatch_resource_manifest_paginates() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("output"); - let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None); - let manifest = serde_json::json!({ - "version": 1, - "entries": { - "https://prod-clientpatch.bluearchiveyostar.com/a": { - "url": "https://prod-clientpatch.bluearchiveyostar.com/a", - "destination": "a", - "bytes": 1, - "blake3": "aa", - }, - "https://prod-clientpatch.bluearchiveyostar.com/b": { - "url": "https://prod-clientpatch.bluearchiveyostar.com/b", - "destination": "b", - "bytes": 2, - "blake3": "bb", - }, - "https://prod-clientpatch.bluearchiveyostar.com/c": { - "url": "https://prod-clientpatch.bluearchiveyostar.com/c", - "destination": "c", - "bytes": 3, - "blake3": "cc", - }, - }, - }); - fs::write( - current_dir.join("official-download-manifest.json"), - serde_json::to_vec(&manifest).unwrap(), - ) - .unwrap(); - - let envelope = dispatch_rpc_method( - &rpc_request( - "resource.manifest", - Some(serde_json::json!({ "offset": 1, "limit": 2 })), - ), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-man-1".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], true); - assert_eq!(value["data"]["total_entries"], 3); - assert_eq!(value["data"]["offset"], 1); - let entries = value["data"]["entries"].as_array().unwrap(); - assert_eq!(entries.len(), 2); - assert_eq!(entries[0]["destination"], "b"); - assert_eq!(entries[1]["destination"], "c"); - - // 非法 limit → 参数错误。 - let envelope = dispatch_rpc_method( - &rpc_request("resource.manifest", Some(serde_json::json!({ "limit": 0 }))), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-man-2".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], false); - assert_eq!(value["error"]["code"], "BAT-ERR-700002"); - - let envelope = dispatch_rpc_method( - &rpc_request( - "resource.list", - Some(serde_json::json!({ "offset": 2, "limit": 1 })), - ), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-man-3".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], true); - let entries = value["data"]["entries"].as_array().unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0]["destination"], "c"); - } - - fn write_resource_index_fixture(repository_path: &Path) { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - runtime.block_on(async { - let repository = SqliteResourceRepository::new(repository_path) - .await - .unwrap(); - for resource in [ - test_resource( - "official/a", - "TableBundles/a.bytes", - ResourceType::TableBundle, - ), - test_resource("official/b", "Bundles/b.bundle", ResourceType::AssetBundle), - test_resource("official/c", "TextAssets/c.json", ResourceType::TextAsset), - ] { - repository.add(resource).await.unwrap(); - } - }); - } - - fn test_resource(id: &str, path: &str, resource_type: ResourceType) -> Resource { - let metadata = if id == "official/c" { - bat_core::domain::ResourceMetadata { - official_release_id: Some("v-current".to_string()), - platform: Some("windows".to_string()), - bundle_path: Some(path.to_string()), - archive_entries: vec!["story/Scenario.json".to_string()], - parse_statuses: vec!["parsed".to_string()], - text_assets: vec!["Scenario".to_string()], - text_unit_count: 4, - text_unit_formats: vec!["json".to_string()], - ..bat_core::domain::ResourceMetadata::default() - } - } else { - bat_core::domain::ResourceMetadata::default() - }; - Resource { - id: id.to_string(), - local_path: PathBuf::from(path), - entry: bat_core::domain::ResourceEntry { - path: path.to_string(), - hash: format!("{id}-hash"), - size: 10, - resource_type, - address: None, - dependencies: Vec::new(), - crc: None, - }, - metadata, - } - } - - #[test] - fn dispatch_resource_index_reports_unavailable_without_database() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("output"); - let repository_path = temp.path().join("missing/resources.sqlite"); - write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None); - let context = test_task_context_with_config(OfficialUpdateConfig { - import_repository: true, - import_resource_repository_path: Some(repository_path.clone()), - ..OfficialUpdateConfig::default() - }); - - let envelope = dispatch_rpc_method( - &rpc_request("resource.index", None), - &state_dir, - &new_daemon_control(), - &context, - "req-index-0".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], false); - assert_eq!( - value["data"]["repository_path"].as_str().unwrap(), - repository_path.to_str().unwrap() - ); - assert!(!repository_path.exists()); - } - - #[test] - fn dispatch_resource_index_filters_and_paginates_repository() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("output"); - let repository_path = temp.path().join("resources.sqlite"); - write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None); - write_resource_index_fixture(&repository_path); - let context = test_task_context_with_config(OfficialUpdateConfig { - import_repository: true, - import_resource_repository_path: Some(repository_path.clone()), - ..OfficialUpdateConfig::default() - }); - - let envelope = dispatch_rpc_method( - &rpc_request( - "resource.index", - Some(serde_json::json!({ - "resource_type": "text_asset", - "release_id": "v-current", - "platform": "windows", - "destination": "TextAssets/c.json", - "bundle_path": "TextAssets/c.json", - "archive_entry": "story/Scenario.json", - "parse_status": "parsed", - "format": "json", - "offset": 0, - "limit": 10 - })), - ), - &state_dir, - &new_daemon_control(), - &context, - "req-index-1".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], true); - assert_eq!(value["data"]["current_version_id"], "v-current"); - assert_eq!( - value["data"]["repository_path"].as_str().unwrap(), - repository_path.to_str().unwrap() - ); - assert_eq!(value["data"]["total_entries"], 1); - let entries = value["data"]["entries"].as_array().unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0]["id"], "official/c"); - assert_eq!(entries[0]["entry"]["resource_type"], "TextAsset"); - assert_eq!(entries[0]["metadata"]["official_release_id"], "v-current"); - assert_eq!(entries[0]["metadata"]["platform"], "windows"); - assert_eq!( - entries[0]["metadata"]["archive_entries"][0], - "story/Scenario.json" - ); - assert_eq!(entries[0]["metadata"]["parse_statuses"][0], "parsed"); - assert_eq!(entries[0]["metadata"]["text_assets"][0], "Scenario"); - assert_eq!(entries[0]["metadata"]["text_unit_count"], 4); - assert_eq!(value["data"]["query"]["official_release_id"], "v-current"); - assert_eq!(value["data"]["query"]["platform"], "windows"); - assert_eq!(value["data"]["query"]["destination"], "TextAssets/c.json"); - assert_eq!(value["data"]["query"]["bundle_path"], "TextAssets/c.json"); - assert_eq!( - value["data"]["query"]["archive_entry"], - "story/Scenario.json" - ); - assert_eq!(value["data"]["query"]["parse_status"], "parsed"); - assert_eq!(value["data"]["query"]["text_unit_format"], "json"); - - let envelope = dispatch_rpc_method( - &rpc_request( - "resource.index", - Some(serde_json::json!({ "resource_type": "unknown" })), - ), - &state_dir, - &new_daemon_control(), - &context, - "req-index-2".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], false); - assert_eq!(value["error"]["code"], "BAT-ERR-700002"); - } - - #[test] - fn dispatch_parse_status_reads_current_cache_summary() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("output"); - let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); - let cache = bat_infrastructure::OfficialParseCache { - version: bat_infrastructure::OFFICIAL_PARSE_CACHE_VERSION, - generated_unix_seconds: 42, - summary: bat_infrastructure::OfficialParseSummary { - manifest_entry_count: 3, - cache_entry_count: 3, - parsed_bundle_count: 2, - text_unit_count: 7, - ..bat_infrastructure::OfficialParseSummary::default() - }, - entries: std::collections::BTreeMap::new(), - }; - fs::write( - current_dir.join(OFFICIAL_PARSE_CACHE_FILE), - serde_json::to_vec(&cache).unwrap(), - ) - .unwrap(); - let textunit_queue = bat_infrastructure::OfficialTextUnitTaskQueue { - queue_version: bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION, - official_release_id: "v-current".to_string(), - previous_release_id: Some("v-previous".to_string()), - generated_unix_seconds: 43, - current_resource_root: current_dir.clone(), - summary: bat_infrastructure::OfficialTextUnitTaskSummary { - resource_candidate_count: 2, - parse_entry_count: 2, - queued_task_count: 1, - text_unit_count: 5, - ..bat_infrastructure::OfficialTextUnitTaskSummary::default() - }, - tasks: Vec::new(), - }; - bat_infrastructure::write_textunit_task_queue_at(¤t_dir, &textunit_queue).unwrap(); - write_textunit_index_fixture(¤t_dir); - - let envelope = dispatch_rpc_method( - &rpc_request("parse.status", None), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-parse-1".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], true); - assert_eq!(value["data"]["current_version_id"], "v-current"); - assert_eq!(value["data"]["generated_unix_seconds"], 42); - assert_eq!(value["data"]["summary"]["text_unit_count"], 7); - assert_eq!(value["data"]["textunit_queue_available"], true); - assert_eq!( - value["data"]["textunit_task_summary"]["queued_task_count"], - 1 - ); - assert_eq!(value["data"]["textunit_task_summary"]["text_unit_count"], 5); - assert_eq!(value["data"]["textunit_index_available"], true); - assert_eq!(value["data"]["textunit_index_summary"]["unit_count"], 2); - } - - fn write_textunit_task_queue_fixture(current_dir: &Path) { - let queue = bat_infrastructure::OfficialTextUnitTaskQueue { - queue_version: bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION, - official_release_id: "v-current".to_string(), - previous_release_id: Some("v-previous".to_string()), - generated_unix_seconds: 43, - current_resource_root: current_dir.to_path_buf(), - summary: bat_infrastructure::OfficialTextUnitTaskSummary { - resource_candidate_count: 2, - parse_entry_count: 2, - queued_task_count: 1, - skipped_parse_failed_count: 1, - text_unit_count: 5, - ..bat_infrastructure::OfficialTextUnitTaskSummary::default() - }, - tasks: vec![ - bat_infrastructure::OfficialTextUnitTask { - task_id: "textunit/v-current/Bundle/a.bundle".to_string(), - official_release_id: "v-current".to_string(), - destination: "Bundle/a.bundle".to_string(), - change_kind: bat_infrastructure::OfficialResourceChangeKind::Added, - url: "https://example.invalid/a.bundle".to_string(), - bytes: 10, - blake3: "hash-a".to_string(), - parse_entry_key: Some("direct:a".to_string()), - archive_entry: None, - source_kind: Some(bat_infrastructure::OfficialParseSourceKind::DirectBundle), - parse_status: Some(bat_infrastructure::OfficialParseStatus::Parsed), - text_asset_count: 1, - text_assets: vec!["Scenario".to_string()], - text_unit_count: 5, - text_unit_formats: vec!["plain".to_string()], - text_unit_error_count: 0, - status: bat_infrastructure::OfficialTextUnitTaskStatus::QueuedOffline, - reason: None, - }, - bat_infrastructure::OfficialTextUnitTask { - task_id: "textunit/v-current/Bundle/b.bundle#assets/b.bundle".to_string(), - official_release_id: "v-current".to_string(), - destination: "Bundle/b.bundle".to_string(), - change_kind: bat_infrastructure::OfficialResourceChangeKind::Modified, - url: "https://example.invalid/b.bundle".to_string(), - bytes: 20, - blake3: "hash-b".to_string(), - parse_entry_key: Some("zip:b".to_string()), - archive_entry: Some("assets/b.bundle".to_string()), - source_kind: Some(bat_infrastructure::OfficialParseSourceKind::ZipEntry), - parse_status: Some(bat_infrastructure::OfficialParseStatus::Failed), - text_asset_count: 0, - text_assets: Vec::new(), - text_unit_count: 0, - text_unit_formats: Vec::new(), - text_unit_error_count: 0, - status: bat_infrastructure::OfficialTextUnitTaskStatus::SkippedParseFailed, - reason: Some("parser failed at serialized object table".to_string()), - }, - ], - }; - bat_infrastructure::write_textunit_task_queue_at(current_dir, &queue).unwrap(); - } - - fn write_translation_task_repository_fixture(current_dir: &Path) { - let queue = bat_infrastructure::read_textunit_task_queue_at(current_dir) - .unwrap() - .unwrap(); - let repository_path = - bat_infrastructure::SqliteTranslationTaskRepository::repository_path(current_dir); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - runtime.block_on(async { - let repository = - bat_infrastructure::SqliteTranslationTaskRepository::new(&repository_path) - .await - .unwrap(); - repository.sync_queue(&queue).await.unwrap(); - repository - .update_status( - "textunit/v-current/Bundle/a.bundle", - bat_infrastructure::TranslationTaskStatus::Failed, - Some("Crowdin provider rejected the payload".to_string()), - Some("crowdin-run-1".to_string()), - ) - .await - .unwrap(); - }); - } - - fn write_textunit_index_fixture(current_dir: &Path) { - let index = bat_infrastructure::OfficialTextUnitIndex { - version: bat_infrastructure::OFFICIAL_TEXTUNIT_INDEX_VERSION, - generated_unix_seconds: 44, - resource_root: current_dir.to_path_buf(), - summary: bat_infrastructure::OfficialTextUnitIndexSummary { - unit_count: 2, - error_count: 1, - skipped_binary_text_asset_count: 0, - }, - units: vec![ - bat_infrastructure::OfficialTextUnitIndexUnit { - id: "direct:a#unit:0".to_string(), - parse_entry_key: "direct:a".to_string(), - source_url: "https://example.invalid/a.bundle".to_string(), - destination: "Bundle/a.bundle".to_string(), - archive_entry: None, - source_kind: bat_infrastructure::OfficialParseSourceKind::DirectBundle, - unity_version: Some("2021.3.56f2".to_string()), - source_text: "こんにちは".to_string(), - serialized_file: Some("CAB-a".to_string()), - path_id: Some(42), - class_id: Some(114), - field_path: Some("Scenario.Message".to_string()), - field_offset: Some(16), - field_byte_size: Some(20), - format: Some("plain".to_string()), - text_source_kind: Some("TypeTreeField".to_string()), - asset_name: None, - context: std::collections::BTreeMap::from([( - "source_kind".to_string(), - "TypeTreeField".to_string(), - )]), - }, - bat_infrastructure::OfficialTextUnitIndexUnit { - id: "direct:b#unit:0".to_string(), - parse_entry_key: "direct:b".to_string(), - source_url: "https://example.invalid/b.bundle".to_string(), - destination: "Bundle/b.bundle".to_string(), - archive_entry: Some("assets/b.bundle".to_string()), - source_kind: bat_infrastructure::OfficialParseSourceKind::ZipEntry, - unity_version: Some("2021.3.56f2".to_string()), - source_text: "{\"text\":\"hello\"}".to_string(), - serialized_file: Some("CAB-b".to_string()), - path_id: Some(7), - class_id: Some(49), - field_path: Some("TextAsset".to_string()), - field_offset: None, - field_byte_size: None, - format: Some("json".to_string()), - text_source_kind: Some("TextAsset".to_string()), - asset_name: Some("Scenario".to_string()), - context: std::collections::BTreeMap::from([ - ("source_kind".to_string(), "TextAsset".to_string()), - ("format".to_string(), "json".to_string()), - ]), - }, - ], - errors: vec![bat_infrastructure::OfficialTextUnitIndexError { - id: "direct:a#extract-error:0".to_string(), - parse_entry_key: "direct:a".to_string(), - source_url: "https://example.invalid/a.bundle".to_string(), - destination: "Bundle/a.bundle".to_string(), - archive_entry: None, - source_kind: bat_infrastructure::OfficialParseSourceKind::DirectBundle, - status: bat_infrastructure::OfficialParseStatus::Parsed, - serialized_file: Some("CAB-a".to_string()), - path_id: Some(42), - class_id: Some(114), - field_path: Some("Managed.Ref".to_string()), - offset: Some(64), - error: "managed reference TypeTree node is not decoded yet".to_string(), - }], - }; - bat_infrastructure::write_textunit_index_at(current_dir, &index).unwrap(); - } - - #[test] - fn dispatch_translation_tasks_filters_current_queue() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("output"); - let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); - write_textunit_task_queue_fixture(¤t_dir); - write_translation_task_repository_fixture(¤t_dir); - - let envelope = dispatch_rpc_method( - &rpc_request( - "translation.tasks", - Some(serde_json::json!({ - "release_id": "v-current", - "path_pattern": "Bundle/*.bundle", - "archive_entry": "assets/b.bundle", - "status": "skipped_parse_failed", - "parse_status": "failed", - "has_reason": true, - "offset": 0, - "limit": 10 - })), - ), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-translation-1".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], true); - assert_eq!(value["data"]["current_version_id"], "v-current"); - assert_eq!(value["data"]["summary"]["queued_task_count"], 1); - assert_eq!(value["data"]["summary"]["skipped_parse_failed_count"], 1); - assert_eq!(value["data"]["total_entries"], 1); - assert_eq!(value["data"]["query"]["official_release_id"], "v-current"); - assert_eq!(value["data"]["query"]["status"], "skipped_parse_failed"); - assert_eq!(value["data"]["query"]["parse_status"], "failed"); - assert_eq!(value["data"]["query"]["has_reason"], true); - let entries = value["data"]["entries"].as_array().unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0]["destination"], "Bundle/b.bundle"); - assert_eq!(entries[0]["archive_entry"], "assets/b.bundle"); - assert_eq!(entries[0]["status"], "skipped_parse_failed"); - assert_eq!( - entries[0]["reason"], - "parser failed at serialized object table" - ); - - let envelope = dispatch_rpc_method( - &rpc_request( - "translation.tasks", - Some(serde_json::json!({ - "task_id": "textunit/v-current/Bundle/a.bundle", - "status": "queued_offline", - "format": "plain", - "has_reason": false, - "offset": 0, - "limit": 10 - })), - ), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-translation-2".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["total_entries"], 1); - assert_eq!(value["data"]["entries"][0]["status"], "queued_offline"); - assert_eq!( - value["data"]["entries"][0]["reason"], - serde_json::Value::Null - ); - - let envelope = dispatch_rpc_method( - &rpc_request( - "translation.tasks", - Some(serde_json::json!({ - "worker_status": "failed", - "has_failure_reason": true, - "offset": 0, - "limit": 10 - })), - ), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-translation-3".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["task_repository_available"], true); - assert_eq!(value["data"]["total_entries"], 1); - assert_eq!(value["data"]["entries"][0]["task_status"], "failed"); - assert_eq!( - value["data"]["entries"][0]["failure_reason"], - "Crowdin provider rejected the payload" - ); - assert_eq!(value["data"]["entries"][0]["attempt_count"], 0); - - let envelope = dispatch_rpc_method( - &rpc_request( - "translation.task.update", - Some(serde_json::json!({ - "task_id": "textunit/v-current/Bundle/a.bundle", - "status": "completed", - "provider_run_id": "crowdin-run-2" - })), - ), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-translation-4".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["entry"]["task_status"], "completed"); - assert_eq!( - value["data"]["entry"]["failure_reason"], - serde_json::Value::Null - ); - assert_eq!(value["data"]["entry"]["provider_run_id"], "crowdin-run-2"); - assert!(value["data"]["entry"]["completed_unix_seconds"].is_number()); - - let envelope = dispatch_rpc_method( - &rpc_request("translation.handoff", None), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-translation-handoff".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], true); - assert_eq!( - value["data"]["handoff_schema_version"], - bat_infrastructure::TRANSLATION_HANDOFF_SCHEMA_VERSION - ); - assert_eq!( - value["data"]["handoff"]["units"].as_array().unwrap().len(), - 2 - ); - assert_eq!(value["data"]["handoff"]["units"][0]["status"], "translated"); - assert_eq!( - value["data"]["handoff"]["provider_runs"][0]["provider_run_id"], - "crowdin-run-2" - ); - } - - #[test] - fn dispatch_parse_text_units_filters_current_index() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("output"); - let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); - write_textunit_index_fixture(¤t_dir); - - let envelope = dispatch_rpc_method( - &rpc_request( - "parse.text_units", - Some(serde_json::json!({ - "path_pattern": "Bundle/*.bundle", - "path_id": 42, - "class_id": 114, - "field_path": "Scenario.Message", - "format": "plain", - "offset": 0, - "limit": 10 - })), - ), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-text-1".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], true); - assert_eq!(value["data"]["total_entries"], 1); - assert_eq!(value["data"]["entries"][0]["source_text"], "こんにちは"); - assert_eq!(value["data"]["entries"][0]["class_id"], 114); - assert_eq!(value["data"]["entries"][0]["field_offset"], 16); - } - - #[test] - fn dispatch_parse_errors_filters_current_index() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("output"); - let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); - write_textunit_index_fixture(¤t_dir); - - let envelope = dispatch_rpc_method( - &rpc_request( - "parse.errors", - Some(serde_json::json!({ - "destination": "Bundle/a.bundle", - "path_id": "42", - "class_id": "114" - })), - ), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-text-err-1".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], true); - assert_eq!(value["data"]["total_entries"], 1); - assert!(value["data"]["entries"][0]["error"] - .as_str() - .unwrap() - .contains("managed reference")); - assert_eq!(value["data"]["entries"][0]["offset"], 64); - } - - #[test] - fn dispatch_parse_status_reports_unavailable_without_cache() { - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("output"); - write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); - - let envelope = dispatch_rpc_method( - &rpc_request("parse.status", None), - &state_dir, - &new_daemon_control(), - &test_task_context(), - "req-parse-2".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], false); - assert_eq!(value["data"]["status"], "pending"); - assert_eq!(value["data"]["status_code"], "parse.pending"); - assert_eq!( - value["data"]["translation_status_code"], - "translation.unavailable" - ); - assert_eq!(value["data"]["current_version_id"], "v-current"); - } - - #[cfg(unix)] - #[test] - fn dispatch_localized_status_verifies_current_release_pointer() { - use std::os::unix::fs::symlink; - - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("output"); - write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); - let localized_root = temp.path().join("localized"); - let localized_version = localized_root - .join(LOCALIZED_VERSIONS_DIR) - .join("v-current"); - fs::create_dir_all(&localized_version).unwrap(); - symlink( - Path::new(LOCALIZED_VERSIONS_DIR).join("v-current"), - localized_root.join(LOCALIZED_CURRENT_LINK), - ) - .unwrap(); - fs::write( - localized_root.join(LOCALIZED_VERSION_STATE_FILE), - serde_json::to_vec(&bat_infrastructure::LocalizedVersionState { - state_version: 1, - official_release_id: "v-current".to_string(), - current_release_id: Some("v-current".to_string()), - status: "localized".to_string(), - updated_unix_seconds: 123, - }) - .unwrap(), - ) - .unwrap(); - fs::write( - localized_version.join(LOCALIZED_PATCH_MANIFEST_FILE), - serde_json::to_vec(&bat_infrastructure::LocalizedPatchManifest { - manifest_version: bat_infrastructure::LOCALIZED_PATCH_MANIFEST_VERSION, - official_release_id: "v-current".to_string(), - localized_release_id: "v-current".to_string(), - generated_unix_seconds: 124, - file_count: 0, - text_asset_operation_count: 0, - files: Vec::new(), - rollback: bat_infrastructure::LocalizedPatchRollbackInfo { - previous_current_target: None, - remove_version_path: localized_version.clone(), - }, - }) - .unwrap(), - ) - .unwrap(); - let tasks = test_task_context_with_config(OfficialUpdateConfig { - localized_output_root: localized_root.clone(), - ..OfficialUpdateConfig::default() - }); - - let envelope = dispatch_rpc_method( - &rpc_request("localized.status", None), - &state_dir, - &new_daemon_control(), - &tasks, - "req-loc-1".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], true); - assert_eq!(value["data"]["status"], "published"); - assert_eq!(value["data"]["status_code"], "localized.published"); - assert_eq!(value["data"]["localized_release_status"], "localized"); - assert_eq!(value["data"]["official_current_version_id"], "v-current"); - assert_eq!(value["data"]["matches_current_official_release"], true); - assert_eq!(value["data"]["current_points_to_published_version"], true); - assert_eq!(value["data"]["patch_manifest_available"], true); - assert_eq!(value["data"]["patch_manifest_matches_release"], true); - assert_eq!(value["data"]["patch_file_count"], 0); - assert_eq!(value["data"]["patch_text_asset_operation_count"], 0); - assert_eq!( - value["data"]["published_version_path"].as_str().unwrap(), - localized_version.to_string_lossy() - ); - } - - #[cfg(unix)] - #[test] - fn dispatch_localized_status_rejects_stale_release_state() { - use std::os::unix::fs::symlink; - - let temp = tempfile::TempDir::new().unwrap(); - let state_dir = temp.path().join("state"); - let output_root = temp.path().join("output"); - write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); - let localized_root = temp.path().join("localized"); - let localized_version = localized_root.join(LOCALIZED_VERSIONS_DIR).join("v-old"); - fs::create_dir_all(&localized_version).unwrap(); - symlink( - Path::new(LOCALIZED_VERSIONS_DIR).join("v-old"), - localized_root.join(LOCALIZED_CURRENT_LINK), - ) - .unwrap(); - fs::write( - localized_root.join(LOCALIZED_VERSION_STATE_FILE), - serde_json::to_vec(&bat_infrastructure::LocalizedVersionState { - state_version: 1, - official_release_id: "v-old".to_string(), - current_release_id: Some("v-old".to_string()), - status: "localized".to_string(), - updated_unix_seconds: 123, - }) - .unwrap(), - ) - .unwrap(); - let tasks = test_task_context_with_config(OfficialUpdateConfig { - localized_output_root: localized_root, - ..OfficialUpdateConfig::default() - }); - - let envelope = dispatch_rpc_method( - &rpc_request("localized.status", None), - &state_dir, - &new_daemon_control(), - &tasks, - "req-loc-2".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], true); - assert_eq!(value["data"]["available"], true); - assert_eq!(value["data"]["status"], "stale"); - assert_eq!(value["data"]["status_code"], "localized.stale"); - assert_eq!(value["data"]["localized_release_status"], "not_localized"); - assert_eq!(value["data"]["matches_current_official_release"], false); - assert_eq!( - value["data"]["published_version_path"], - serde_json::Value::Null - ); - } - - #[test] - fn dispatch_daemon_clean_stable_reports_not_implemented() { - let temp = tempfile::TempDir::new().unwrap(); - let envelope = dispatch_rpc_method( - &rpc_request("daemon.clean-stable", None), - temp.path(), - &new_daemon_control(), - &test_task_context(), - "req-clean-1".to_string(), - ); - let value = serde_json::to_value(&envelope).unwrap(); - assert_eq!(value["ok"], false); - assert_eq!(value["error"]["code"], "BAT-ERR-700003"); - } + app::main(); }