mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-23 08:55:16 +08:00
@@ -1,9 +1,9 @@
|
||||
use bat_adapters::official::yostar_jp::PatchPlatform;
|
||||
use bat_infrastructure::{
|
||||
open_append_file, read_file_no_symlink, validate_output_root, validate_runtime_state_dir,
|
||||
write_file_atomic, OfficialServerInfoSource, OfficialUpdateConfig, OfficialUpdateProgress,
|
||||
OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus, OfficialVerificationSummary,
|
||||
PRIVATE_FILE_MODE,
|
||||
lexical_absolute, open_append_file, read_file_no_symlink, validate_output_root,
|
||||
validate_runtime_state_dir, write_file_atomic, OfficialServerInfoSource, OfficialUpdateConfig,
|
||||
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus,
|
||||
OfficialVerificationSummary, PRIVATE_FILE_MODE,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
@@ -27,9 +27,13 @@ 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 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];
|
||||
@@ -244,6 +248,9 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
||||
let service = OfficialUpdateService::new();
|
||||
let mut logger = ProgressLogger::new(options.progress);
|
||||
let daemon_state_dir = options.state_dir.clone();
|
||||
if options.daemon_child {
|
||||
logger.attach_structured_log(daemon_structured_log_path(&daemon_state_dir));
|
||||
}
|
||||
let daemon_control = if options.daemon_child {
|
||||
Some(new_daemon_control())
|
||||
} else {
|
||||
@@ -315,13 +322,23 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
||||
last_update_status: None,
|
||||
last_error: None,
|
||||
next_retry_seconds: None,
|
||||
last_success_unix_seconds: None,
|
||||
next_check_unix_seconds: None,
|
||||
pending_scheduled_force,
|
||||
next_forced_refresh_at,
|
||||
},
|
||||
);
|
||||
match service.run_with_progress_and_cancellation(
|
||||
&iteration_config,
|
||||
|event| logger.log(event),
|
||||
|event| {
|
||||
record_daemon_progress(
|
||||
options.daemon_child,
|
||||
&daemon_state_dir,
|
||||
&mut logger,
|
||||
&event,
|
||||
);
|
||||
logger.log(event);
|
||||
},
|
||||
|| daemon_control_stop_requested(daemon_control.as_ref()),
|
||||
) {
|
||||
Ok(report) => {
|
||||
@@ -342,6 +359,8 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
||||
last_update_status: Some(report.update_status.as_str().to_string()),
|
||||
last_error: None,
|
||||
next_retry_seconds: Some(sleep_for.as_secs()),
|
||||
last_success_unix_seconds: Some(unix_seconds_now()),
|
||||
next_check_unix_seconds: Some(unix_seconds_after(sleep_for)),
|
||||
pending_scheduled_force,
|
||||
next_forced_refresh_at,
|
||||
},
|
||||
@@ -369,6 +388,8 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
||||
last_update_status: None,
|
||||
last_error: Some(localize_error_message(&error.to_string())),
|
||||
next_retry_seconds: Some(sleep_for.as_secs()),
|
||||
last_success_unix_seconds: None,
|
||||
next_check_unix_seconds: Some(unix_seconds_after(sleep_for)),
|
||||
pending_scheduled_force,
|
||||
next_forced_refresh_at,
|
||||
},
|
||||
@@ -431,6 +452,8 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
||||
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,
|
||||
},
|
||||
@@ -450,22 +473,46 @@ struct DaemonStatusFile {
|
||||
resource_output_root: PathBuf,
|
||||
state_dir: PathBuf,
|
||||
log_path: PathBuf,
|
||||
#[serde(default)]
|
||||
structured_log_path: Option<PathBuf>,
|
||||
started_unix_seconds: u64,
|
||||
updated_unix_seconds: u64,
|
||||
#[serde(default)]
|
||||
last_success_unix_seconds: Option<u64>,
|
||||
#[serde(default)]
|
||||
next_check_unix_seconds: Option<u64>,
|
||||
last_update_status: Option<String>,
|
||||
last_error: Option<String>,
|
||||
next_retry_seconds: Option<u64>,
|
||||
#[serde(default)]
|
||||
current_stage: Option<String>,
|
||||
#[serde(default)]
|
||||
current_message: Option<String>,
|
||||
#[serde(default)]
|
||||
download_progress: Option<DaemonDownloadProgress>,
|
||||
pending_scheduled_force: bool,
|
||||
next_forced_refresh_unix_seconds: Option<u64>,
|
||||
command: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct DaemonDownloadProgress {
|
||||
index: usize,
|
||||
total: usize,
|
||||
url: String,
|
||||
status: Option<String>,
|
||||
bytes: Option<u64>,
|
||||
transferred_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
@@ -557,6 +604,8 @@ fn record_daemon_status(
|
||||
update.last_update_status,
|
||||
update.last_error,
|
||||
update.next_retry_seconds,
|
||||
update.last_success_unix_seconds,
|
||||
update.next_check_unix_seconds,
|
||||
update.pending_scheduled_force,
|
||||
update.next_forced_refresh_at,
|
||||
) {
|
||||
@@ -564,6 +613,21 @@ fn record_daemon_status(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -1216,6 +1280,7 @@ struct DaemonStartReport {
|
||||
pid_path: PathBuf,
|
||||
status_path: PathBuf,
|
||||
log_path: PathBuf,
|
||||
structured_log_path: PathBuf,
|
||||
socket_path: PathBuf,
|
||||
}
|
||||
|
||||
@@ -1234,12 +1299,19 @@ struct DaemonStatusReport {
|
||||
stale_pid_file: bool,
|
||||
stale_socket: bool,
|
||||
log_path: Option<PathBuf>,
|
||||
structured_log_path: Option<PathBuf>,
|
||||
rotated_structured_log_paths: Vec<PathBuf>,
|
||||
started_unix_seconds: Option<u64>,
|
||||
updated_unix_seconds: Option<u64>,
|
||||
last_success_unix_seconds: Option<u64>,
|
||||
next_check_unix_seconds: Option<u64>,
|
||||
daemon_state: Option<String>,
|
||||
last_update_status: Option<String>,
|
||||
last_error: Option<String>,
|
||||
next_retry_seconds: Option<u64>,
|
||||
current_stage: Option<String>,
|
||||
current_message: Option<String>,
|
||||
download_progress: Option<DaemonDownloadProgress>,
|
||||
pending_scheduled_force: Option<bool>,
|
||||
next_forced_refresh_unix_seconds: Option<u64>,
|
||||
command: Option<Vec<String>>,
|
||||
@@ -1289,6 +1361,7 @@ fn start_daemon_with_args(
|
||||
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)? {
|
||||
@@ -1339,11 +1412,17 @@ fn start_daemon_with_args(
|
||||
resource_output_root: resource_output_root.clone(),
|
||||
state_dir: state_dir.clone(),
|
||||
log_path: log_path.clone(),
|
||||
structured_log_path: Some(structured_log_path.clone()),
|
||||
started_unix_seconds: unix_seconds_now(),
|
||||
updated_unix_seconds: unix_seconds_now(),
|
||||
last_success_unix_seconds: None,
|
||||
next_check_unix_seconds: None,
|
||||
last_update_status: None,
|
||||
last_error: None,
|
||||
next_retry_seconds: None,
|
||||
current_stage: None,
|
||||
current_message: None,
|
||||
download_progress: None,
|
||||
pending_scheduled_force: false,
|
||||
next_forced_refresh_unix_seconds: None,
|
||||
command,
|
||||
@@ -1359,6 +1438,7 @@ fn start_daemon_with_args(
|
||||
pid_path,
|
||||
status_path,
|
||||
log_path,
|
||||
structured_log_path,
|
||||
socket_path,
|
||||
})
|
||||
}
|
||||
@@ -1504,6 +1584,12 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
|
||||
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);
|
||||
|
||||
Ok(DaemonStatusReport {
|
||||
status: if running { "running" } else { "stopped" },
|
||||
@@ -1530,12 +1616,20 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
|
||||
.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()
|
||||
@@ -1546,6 +1640,15 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
|
||||
next_retry_seconds: status_file
|
||||
.as_ref()
|
||||
.and_then(|status| status.next_retry_seconds),
|
||||
current_stage: status_file
|
||||
.as_ref()
|
||||
.and_then(|status| status.current_stage.clone()),
|
||||
current_message: status_file
|
||||
.as_ref()
|
||||
.and_then(|status| status.current_message.clone()),
|
||||
download_progress: status_file
|
||||
.as_ref()
|
||||
.and_then(|status| status.download_progress.clone()),
|
||||
pending_scheduled_force: status_file
|
||||
.as_ref()
|
||||
.map(|status| status.pending_scheduled_force),
|
||||
@@ -1776,12 +1879,19 @@ fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
|
||||
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_json_field(value, "download_progress", "下载进度");
|
||||
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") {
|
||||
@@ -1868,6 +1978,14 @@ fn print_list(label: &str, values: &[String], limit: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_daemon_download_progress(progress: &DaemonDownloadProgress) -> String {
|
||||
let status = progress.status.as_deref().unwrap_or("running");
|
||||
format!(
|
||||
"{}/{} {} {}",
|
||||
progress.index, progress.total, status, progress.url
|
||||
)
|
||||
}
|
||||
|
||||
fn print_verification_summary(summary: &OfficialVerificationSummary) {
|
||||
println!(" 校验摘要:");
|
||||
for line in verification_summary_lines(summary) {
|
||||
@@ -1946,6 +2064,11 @@ impl HumanReport for OfficialUpdateReport {
|
||||
print_field("本地审计", format_bool(self.audit_local));
|
||||
print_field("自动修复", format_bool(self.repair));
|
||||
print_field("dry-run", format_bool(self.dry_run));
|
||||
print_path_field("输出根目录", &self.output_root);
|
||||
print_path_field("active release", &self.active_resource_root);
|
||||
print_path_field("current", &self.current_path);
|
||||
print_optional_path_field("staging", self.staging_path.as_ref());
|
||||
print_optional_path_field("published", self.published_version_path.as_ref());
|
||||
print_path_field("snapshot", &self.snapshot_path);
|
||||
print_path_field("manifest", &self.download_manifest);
|
||||
print_optional_path_field("写入 snapshot", self.snapshot_written.as_ref());
|
||||
@@ -1990,6 +2113,7 @@ impl HumanReport for DaemonStartReport {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -2006,12 +2130,26 @@ impl HumanReport for DaemonStatusReport {
|
||||
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));
|
||||
}
|
||||
print_optional_path_field("资源目录", self.resource_output_root.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(())
|
||||
}
|
||||
}
|
||||
@@ -2051,6 +2189,7 @@ impl HumanReport for VerifyCommandReport {
|
||||
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);
|
||||
@@ -2166,6 +2305,7 @@ struct VerifyCommandReport {
|
||||
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,
|
||||
@@ -2189,8 +2329,9 @@ fn run_verify_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
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(
|
||||
&config.output_root,
|
||||
&verified_resource_root,
|
||||
&config.curl_command,
|
||||
)
|
||||
.verify_local_download_manifest()
|
||||
@@ -2229,6 +2370,7 @@ fn run_verify_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
},
|
||||
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(),
|
||||
@@ -2693,6 +2835,8 @@ fn update_daemon_status(
|
||||
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,
|
||||
) -> anyhow::Result<()> {
|
||||
@@ -2704,11 +2848,17 @@ fn update_daemon_status(
|
||||
resource_output_root: OfficialUpdateConfig::default().output_root,
|
||||
state_dir: state_dir.to_path_buf(),
|
||||
log_path: daemon_log_path(state_dir),
|
||||
structured_log_path: Some(daemon_structured_log_path(state_dir)),
|
||||
started_unix_seconds: unix_seconds_now(),
|
||||
updated_unix_seconds: unix_seconds_now(),
|
||||
last_success_unix_seconds: None,
|
||||
next_check_unix_seconds: None,
|
||||
last_update_status: None,
|
||||
last_error: None,
|
||||
next_retry_seconds: None,
|
||||
current_stage: None,
|
||||
current_message: None,
|
||||
download_progress: None,
|
||||
pending_scheduled_force: false,
|
||||
next_forced_refresh_unix_seconds: None,
|
||||
command: env::args().collect(),
|
||||
@@ -2719,11 +2869,44 @@ fn update_daemon_status(
|
||||
status.last_update_status = last_update_status;
|
||||
status.last_error = last_error;
|
||||
status.next_retry_seconds = next_retry_seconds;
|
||||
if last_success_unix_seconds.is_some() {
|
||||
status.last_success_unix_seconds = last_success_unix_seconds;
|
||||
}
|
||||
status.next_check_unix_seconds = next_check_unix_seconds;
|
||||
if state != "running" {
|
||||
status.current_stage = None;
|
||||
status.current_message = None;
|
||||
status.download_progress = None;
|
||||
}
|
||||
status.pending_scheduled_force = pending_scheduled_force;
|
||||
status.next_forced_refresh_unix_seconds = system_time_to_unix_seconds(next_forced_refresh_at);
|
||||
write_daemon_status_file(&status_path, &status)
|
||||
}
|
||||
|
||||
fn update_daemon_progress(state_dir: &Path, event: &OfficialUpdateProgress) -> anyhow::Result<()> {
|
||||
let status_path = daemon_status_path(state_dir);
|
||||
let Some(mut status) = read_daemon_status_file(&status_path)? else {
|
||||
return Ok(());
|
||||
};
|
||||
status.pid = std::process::id();
|
||||
status.updated_unix_seconds = unix_seconds_now();
|
||||
status.current_stage = Some(event.stage.to_string());
|
||||
status.current_message = Some(event.message.clone());
|
||||
status.download_progress = match (event.download_index, event.download_total) {
|
||||
(Some(index), Some(total)) => Some(DaemonDownloadProgress {
|
||||
index,
|
||||
total,
|
||||
url: event.download_url.clone().unwrap_or_default(),
|
||||
status: event.download_status.clone(),
|
||||
bytes: event.download_bytes,
|
||||
transferred_bytes: event.download_transferred_bytes,
|
||||
}),
|
||||
_ if event.stage != "download" => None,
|
||||
_ => status.download_progress,
|
||||
};
|
||||
write_daemon_status_file(&status_path, &status)
|
||||
}
|
||||
|
||||
fn update_daemon_state_only(state_dir: &Path, state: &str) -> anyhow::Result<()> {
|
||||
let status_path = daemon_status_path(state_dir);
|
||||
let Some(mut status) = read_daemon_status_file(&status_path)? else {
|
||||
@@ -2757,6 +2940,25 @@ fn daemon_log_path(output_root: &Path) -> PathBuf {
|
||||
output_root.join(DAEMON_LOG_FILE)
|
||||
}
|
||||
|
||||
fn daemon_structured_log_path(output_root: &Path) -> PathBuf {
|
||||
output_root.join(DAEMON_STRUCTURED_LOG_FILE)
|
||||
}
|
||||
|
||||
fn rotated_structured_log_path(path: &Path, index: usize) -> PathBuf {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or(DAEMON_STRUCTURED_LOG_FILE);
|
||||
path.with_file_name(format!("{file_name}.{index}"))
|
||||
}
|
||||
|
||||
fn rotated_structured_log_paths(path: &Path) -> Vec<PathBuf> {
|
||||
(1..=STRUCTURED_LOG_ROTATE_KEEP)
|
||||
.map(|index| rotated_structured_log_path(path, index))
|
||||
.filter(|path| path_exists_no_follow(path).unwrap_or(false))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn daemon_socket_path(output_root: &Path) -> PathBuf {
|
||||
output_root.join(DAEMON_SOCKET_FILE)
|
||||
}
|
||||
@@ -2765,6 +2967,30 @@ fn daemon_control_lock_path(output_root: &Path) -> PathBuf {
|
||||
output_root.join(DAEMON_CONTROL_LOCK_FILE)
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -2882,6 +3108,10 @@ fn unix_seconds_now() -> u64 {
|
||||
.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()
|
||||
@@ -3026,6 +3256,7 @@ fn duration_until(deadline: SystemTime, now: SystemTime) -> Duration {
|
||||
struct ProgressLogger {
|
||||
enabled: bool,
|
||||
started_at: Instant,
|
||||
structured: Option<RotatingStructuredLogger>,
|
||||
}
|
||||
|
||||
impl ProgressLogger {
|
||||
@@ -3033,23 +3264,175 @@ impl ProgressLogger {
|
||||
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_text(event.stage, event.message);
|
||||
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.as_ref()
|
||||
message
|
||||
);
|
||||
if let Some(structured) = self.structured.as_mut() {
|
||||
let event = OfficialUpdateProgress::new(stage_to_static(stage), message);
|
||||
let _ = structured.write_event(self.started_at.elapsed(), &event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RotatingStructuredLogger {
|
||||
path: PathBuf,
|
||||
max_bytes: u64,
|
||||
keep: usize,
|
||||
}
|
||||
|
||||
impl RotatingStructuredLogger {
|
||||
fn new(path: PathBuf, max_bytes: u64, keep: usize) -> Self {
|
||||
Self {
|
||||
path,
|
||||
max_bytes,
|
||||
keep,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_event(
|
||||
&mut self,
|
||||
elapsed: Duration,
|
||||
event: &OfficialUpdateProgress,
|
||||
) -> anyhow::Result<()> {
|
||||
let payload = serde_json::json!({
|
||||
"timestamp_unix_seconds": unix_seconds_now(),
|
||||
"elapsed_ms": elapsed.as_millis(),
|
||||
"level": "info",
|
||||
"stage": event.stage,
|
||||
"stage_label": localized_stage(event.stage),
|
||||
"message": event.message.as_str(),
|
||||
"download": event.download_index.map(|index| serde_json::json!({
|
||||
"index": index,
|
||||
"total": event.download_total.unwrap_or(index),
|
||||
"url": event.download_url.as_deref(),
|
||||
"status": event.download_status.as_deref(),
|
||||
"bytes": event.download_bytes,
|
||||
"transferred_bytes": event.download_transferred_bytes,
|
||||
})),
|
||||
});
|
||||
let mut line = serde_json::to_vec(&payload)?;
|
||||
line.push(b'\n');
|
||||
self.rotate_if_needed(line.len() as u64)?;
|
||||
let mut file = open_append_file(&self.path, PRIVATE_FILE_MODE, "结构化日志")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
file.write_all(&line)?;
|
||||
file.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rotate_if_needed(&self, incoming_bytes: u64) -> anyhow::Result<()> {
|
||||
let current_len = match fs::symlink_metadata(&self.path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"结构化日志不能是 symlink:{}",
|
||||
self.path.display()
|
||||
))
|
||||
}
|
||||
Ok(metadata) if metadata.is_file() => metadata.len(),
|
||||
Ok(_) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"结构化日志已存在但不是普通文件:{}",
|
||||
self.path.display()
|
||||
))
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if current_len.saturating_add(incoming_bytes) <= self.max_bytes {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for index in (1..=self.keep).rev() {
|
||||
let from = if index == 1 {
|
||||
self.path.clone()
|
||||
} else {
|
||||
rotated_structured_log_path(&self.path, index - 1)
|
||||
};
|
||||
let to = rotated_structured_log_path(&self.path, index);
|
||||
if !path_exists_no_follow(&from)? {
|
||||
continue;
|
||||
}
|
||||
if path_exists_no_follow(&to)? {
|
||||
fs::remove_file(&to)?;
|
||||
}
|
||||
fs::rename(&from, &to)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn stage_to_static(stage: &str) -> &'static str {
|
||||
match stage {
|
||||
"start" => "start",
|
||||
"lock" => "lock",
|
||||
"bootstrap" => "bootstrap",
|
||||
"launcher" => "launcher",
|
||||
"bootstrap-cache" => "bootstrap-cache",
|
||||
"game-main-config" => "game-main-config",
|
||||
"metadata" => "metadata",
|
||||
"server-info" => "server-info",
|
||||
"discovery" => "discovery",
|
||||
"markers" => "markers",
|
||||
"marker" => "marker",
|
||||
"catalog" => "catalog",
|
||||
"local-state" => "local-state",
|
||||
"audit" => "audit",
|
||||
"decision" => "decision",
|
||||
"plan" => "plan",
|
||||
"download" => "download",
|
||||
"snapshot" => "snapshot",
|
||||
"publish" => "publish",
|
||||
"finish" => "finish",
|
||||
"watch" => "watch",
|
||||
"daemon" => "daemon",
|
||||
"dry-run" => "dry-run",
|
||||
_ => "log",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3075,6 +3458,7 @@ fn localized_stage(stage: &str) -> &str {
|
||||
"audit" => "审计",
|
||||
"dry-run" => "试运行",
|
||||
"download" => "下载",
|
||||
"publish" => "发布",
|
||||
"resource" => "资源",
|
||||
"finish" => "完成",
|
||||
"watch" => "常驻",
|
||||
@@ -3477,8 +3861,10 @@ fn print_usage(binary: &str) {
|
||||
eprintln!();
|
||||
eprintln!("Sync:");
|
||||
eprintln!(" --platforms <LIST> Platforms, e.g. Windows,Android");
|
||||
eprintln!(" --output <DIR> Resource output dir (default: ./bat-resources)");
|
||||
eprintln!(" --snapshot <PATH> Snapshot path (default: <output>/official-sync-snapshot.json)");
|
||||
eprintln!(
|
||||
" --output <DIR> Resource publish root (default: ./bat-resources)"
|
||||
);
|
||||
eprintln!(" --snapshot <PATH> Override snapshot path (default: <output>/current/official-sync-snapshot.json)");
|
||||
eprintln!(" --curl <PATH> curl executable (default: curl)");
|
||||
eprintln!(" --unzip <PATH> unzip executable (default: unzip)");
|
||||
eprintln!(" --dry-run Do not write sync state");
|
||||
@@ -3506,8 +3892,8 @@ fn print_usage(binary: &str) {
|
||||
eprintln!();
|
||||
eprintln!("Defaults:");
|
||||
eprintln!(" platforms: Windows,Android");
|
||||
eprintln!(" resource output: ./bat-resources");
|
||||
eprintln!(" daemon state: /tmp/bat-pid (bat.sock, bat.pid, bat-status.json, bat-daemon.log)");
|
||||
eprintln!(" resource output: ./bat-resources (current -> versions/<id>, .staging/<id>)");
|
||||
eprintln!(" daemon state: /tmp/bat-pid (bat.sock, bat.pid, bat-status.json, bat-daemon.log, bat-events.jsonl)");
|
||||
eprintln!(" forced refresh: {DAILY_FORCED_REFRESH_LABEL}");
|
||||
}
|
||||
|
||||
@@ -3877,6 +4263,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn active_official_resource_root_uses_current_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let output = temp.path().join("resources");
|
||||
let version = output.join("versions").join("v1");
|
||||
fs::create_dir_all(&version).unwrap();
|
||||
symlink(Path::new("versions").join("v1"), output.join("current")).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
active_official_resource_root(&output).unwrap(),
|
||||
lexical_absolute(&version).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn daemon_rpc_rejects_socket_symlink() {
|
||||
@@ -4028,6 +4431,80 @@ mod tests {
|
||||
assert_eq!(fs::read(&outside).unwrap(), b"outside");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_status_tracks_progress_success_and_next_check() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
let output_root = temp.path().join("resources");
|
||||
fs::create_dir_all(&state_dir).unwrap();
|
||||
fs::create_dir_all(&output_root).unwrap();
|
||||
write_daemon_status_file(
|
||||
&daemon_status_path(&state_dir),
|
||||
&test_daemon_status_file(&state_dir, &output_root),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut event = OfficialUpdateProgress::new("download", "(2/19) 开始 fixture");
|
||||
event.download_index = Some(2);
|
||||
event.download_total = Some(19);
|
||||
event.download_url = Some("https://prod-clientpatch.bluearchiveyostar.com/file.zip".into());
|
||||
update_daemon_progress(&state_dir, &event).unwrap();
|
||||
|
||||
let report = build_daemon_status_report(&state_dir).unwrap();
|
||||
assert_eq!(report.current_stage.as_deref(), Some("download"));
|
||||
assert_eq!(report.download_progress.as_ref().unwrap().index, 2);
|
||||
assert_eq!(report.download_progress.as_ref().unwrap().total, 19);
|
||||
|
||||
let next_check = unix_seconds_after(Duration::from_secs(30));
|
||||
update_daemon_status(
|
||||
&state_dir,
|
||||
"sleeping",
|
||||
Some("downloaded".to_string()),
|
||||
None,
|
||||
Some(30),
|
||||
Some(123456),
|
||||
Some(next_check),
|
||||
false,
|
||||
SystemTime::now() + Duration::from_secs(3600),
|
||||
)
|
||||
.unwrap();
|
||||
let report = build_daemon_status_report(&state_dir).unwrap();
|
||||
assert_eq!(report.last_success_unix_seconds, Some(123456));
|
||||
assert_eq!(report.next_check_unix_seconds, Some(next_check));
|
||||
assert!(report.current_stage.is_none());
|
||||
assert!(report.download_progress.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_logger_writes_jsonl_and_rotates() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let path = temp.path().join("bat-events.jsonl");
|
||||
let mut logger = RotatingStructuredLogger::new(path.clone(), 180, 2);
|
||||
let mut event = OfficialUpdateProgress::new("download", "下载进度 fixture");
|
||||
event.download_index = Some(1);
|
||||
event.download_total = Some(2);
|
||||
event.download_url = Some("https://prod-clientpatch.bluearchiveyostar.com/a.zip".into());
|
||||
|
||||
for _ in 0..8 {
|
||||
logger
|
||||
.write_event(Duration::from_millis(42), &event)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
assert!(path.exists());
|
||||
assert!(rotated_structured_log_path(&path, 1).exists());
|
||||
let line = fs::read_to_string(&path)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let value: serde_json::Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(value["stage"], "download");
|
||||
assert_eq!(value["download"]["index"], 1);
|
||||
assert_eq!(value["download"]["total"], 2);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_pid_file_rejects_symlink() {
|
||||
@@ -4295,11 +4772,17 @@ mod tests {
|
||||
resource_output_root: output_root.to_path_buf(),
|
||||
state_dir: state_dir.to_path_buf(),
|
||||
log_path: daemon_log_path(state_dir),
|
||||
structured_log_path: Some(daemon_structured_log_path(state_dir)),
|
||||
started_unix_seconds: unix_seconds_now(),
|
||||
updated_unix_seconds: unix_seconds_now(),
|
||||
last_success_unix_seconds: Some(unix_seconds_now()),
|
||||
next_check_unix_seconds: Some(unix_seconds_after(Duration::from_secs(60))),
|
||||
last_update_status: Some("up_to_date".to_string()),
|
||||
last_error: None,
|
||||
next_retry_seconds: Some(60),
|
||||
current_stage: None,
|
||||
current_message: None,
|
||||
download_progress: None,
|
||||
pending_scheduled_force: false,
|
||||
next_forced_refresh_unix_seconds: None,
|
||||
command: vec!["bat".to_string(), "--daemon-child".to_string()],
|
||||
|
||||
Reference in New Issue
Block a user