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 ===================================================================================== "#; 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"); } }