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