feat: publish official resources atomically

Fixes #9

Fixes #11
This commit is contained in:
2026-07-14 00:10:39 +08:00
parent 45aba2fb2b
commit 2ff99ae9e3
10 changed files with 1055 additions and 63 deletions
+495 -12
View File
@@ -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(&current_path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(output_root.to_path_buf());
}
Err(error) => return Err(error.into()),
};
if !metadata.file_type().is_symlink() {
return Err(anyhow::anyhow!(
"current 已存在但不是 symlink{}",
current_path.display()
));
}
let target = fs::read_link(&current_path)?;
let target = if target.is_absolute() {
target
} else {
output_root.join(target)
};
lexical_absolute(&target).map_err(anyhow::Error::msg)
}
fn daemon_child_args(options: &CliOptions) -> Vec<String> {
let mut args = Vec::new();
let config = &options.config;
@@ -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()],
+478 -9
View File
@@ -31,11 +31,17 @@ use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Current official update snapshot schema version.
pub const OFFICIAL_UPDATE_SNAPSHOT_VERSION: u32 = 2;
/// Current official bootstrap cache schema version.
pub const OFFICIAL_BOOTSTRAP_CACHE_VERSION: u32 = 1;
const OFFICIAL_CURRENT_LINK: &str = "current";
const OFFICIAL_VERSIONS_DIR: &str = "versions";
const OFFICIAL_STAGING_DIR: &str = ".staging";
const OFFICIAL_DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json";
const OFFICIAL_SYNC_SNAPSHOT_FILE: &str = "official-sync-snapshot.json";
/// Server-info input for an official update run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -379,6 +385,16 @@ pub struct OfficialUpdateReport {
pub addressables_root: String,
/// Platform set used by the run.
pub platforms: Vec<PatchPlatform>,
/// Managed publish root containing `current`, `versions`, and staging.
pub output_root: PathBuf,
/// Active resource root used for local audit before this run.
pub active_resource_root: PathBuf,
/// Atomic `current` pointer path.
pub current_path: PathBuf,
/// Versioned release directory after a successful publish.
pub published_version_path: Option<PathBuf>,
/// Staging directory used during download before publish.
pub staging_path: Option<PathBuf>,
/// Sync snapshot path.
pub snapshot_path: PathBuf,
/// Whether a previous snapshot existed.
@@ -446,6 +462,18 @@ pub struct OfficialUpdateProgress {
pub stage: &'static str,
/// Human-readable status line.
pub message: String,
/// One-based download index when the event represents URL download work.
pub download_index: Option<usize>,
/// Total URL count for the current download plan.
pub download_total: Option<usize>,
/// Current official URL for download progress.
pub download_url: Option<String>,
/// Stable pull status label when a URL finished.
pub download_status: Option<String>,
/// Final local byte count for the URL when known.
pub download_bytes: Option<u64>,
/// Bytes transferred in this run for the URL when known.
pub download_transferred_bytes: Option<u64>,
}
impl OfficialUpdateProgress {
@@ -454,8 +482,192 @@ impl OfficialUpdateProgress {
Self {
stage,
message: message.into(),
download_index: None,
download_total: None,
download_url: None,
download_status: None,
download_bytes: None,
download_transferred_bytes: None,
}
}
fn with_download_progress(mut self, event: &OfficialResourcePullProgress) -> Self {
self.download_index = Some(event.index);
self.download_total = Some(event.total);
self.download_url = Some(event.url.clone());
self.download_status = event.status.map(|status| status.as_str().to_string());
self.download_bytes = event.bytes;
self.download_transferred_bytes = event.transferred_bytes;
self
}
}
#[derive(Debug, Clone)]
struct OfficialPublishLayout {
root: PathBuf,
current_path: PathBuf,
versions_dir: PathBuf,
staging_dir: PathBuf,
}
#[derive(Debug, Clone)]
struct OfficialPublishPlan {
id: String,
staging_path: PathBuf,
version_path: PathBuf,
}
impl OfficialPublishLayout {
fn new(root: &Path) -> Self {
Self {
root: root.to_path_buf(),
current_path: root.join(OFFICIAL_CURRENT_LINK),
versions_dir: root.join(OFFICIAL_VERSIONS_DIR),
staging_dir: root.join(OFFICIAL_STAGING_DIR),
}
}
fn active_resource_root(&self) -> Result<PathBuf, String> {
if let Some(current_target) = self.current_target()? {
return Ok(current_target);
}
// Legacy fallback for directories produced before atomic publishing.
// The next non-dry-run update will seed staging from this tree and
// publish it under `versions/<id>` before switching `current`.
Ok(self.root.clone())
}
fn current_target(&self) -> Result<Option<PathBuf>, String> {
let metadata = match fs::symlink_metadata(&self.current_path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(format!(
"读取 current 指针失败 {}{error}",
self.current_path.display()
))
}
};
if !metadata.file_type().is_symlink() {
return Err(format!(
"current 必须是指向 versioned 目录的 symlink{}",
self.current_path.display()
));
}
let target = fs::read_link(&self.current_path).map_err(|error| {
format!(
"读取 current symlink 目标失败 {}{error}",
self.current_path.display()
)
})?;
let target = if target.is_absolute() {
target
} else {
self.root.join(target)
};
ensure_path_within_root(&self.root, &target)?;
ensure_safe_directory_path(&target, "current versioned 目录")?;
Ok(Some(target))
}
fn has_current_pointer(&self) -> Result<bool, String> {
Ok(self.current_target()?.is_some())
}
fn plan(&self, snapshot: &OfficialUpdateSnapshot) -> OfficialPublishPlan {
let id = publish_id(snapshot);
OfficialPublishPlan {
staging_path: self.staging_dir.join(&id),
version_path: self.versions_dir.join(&id),
id,
}
}
fn prepare_staging(&self, plan: &OfficialPublishPlan) -> Result<(), String> {
validate_output_root(&self.root)?;
ensure_safe_directory_path(&self.staging_dir, "官方资源 staging 根目录")?;
fs::create_dir_all(&self.staging_dir).map_err(|error| {
format!(
"创建官方资源 staging 根目录失败 {}{error}",
self.staging_dir.display()
)
})?;
ensure_safe_directory_path(&self.staging_dir, "官方资源 staging 根目录")?;
if path_exists_no_follow(&plan.staging_path)? {
ensure_safe_directory_path(&plan.staging_path, "官方资源 staging 目录")?;
fs::remove_dir_all(&plan.staging_path).map_err(|error| {
format!(
"清理官方资源 staging 目录失败 {}{error}",
plan.staging_path.display()
)
})?;
}
if path_exists_no_follow(&plan.version_path)? {
return Err(format!(
"versioned 目录已存在,拒绝覆盖:{}",
plan.version_path.display()
));
}
fs::create_dir_all(&plan.staging_path).map_err(|error| {
format!(
"创建官方资源 staging 目录失败 {}{error}",
plan.staging_path.display()
)
})?;
ensure_safe_directory_path(&plan.staging_path, "官方资源 staging 目录")
}
fn seed_staging_from_active(
&self,
active_root: &Path,
staging_root: &Path,
) -> Result<(), String> {
if active_root == self.root && !self.legacy_manifest_exists()? {
return Ok(());
}
if !path_exists_no_follow(active_root)? {
return Ok(());
}
copy_tree_no_symlink(active_root, staging_root, active_root == self.root)
}
fn legacy_manifest_exists(&self) -> Result<bool, String> {
path_exists_no_follow(&self.root.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE))
}
fn publish(&self, plan: &OfficialPublishPlan) -> Result<PathBuf, String> {
ensure_safe_directory_path(&self.versions_dir, "官方资源 versions 根目录")?;
fs::create_dir_all(&self.versions_dir).map_err(|error| {
format!(
"创建官方资源 versions 根目录失败 {}{error}",
self.versions_dir.display()
)
})?;
ensure_safe_directory_path(&self.versions_dir, "官方资源 versions 根目录")?;
ensure_safe_directory_path(&plan.staging_path, "官方资源 staging 目录")?;
if path_exists_no_follow(&plan.version_path)? {
return Err(format!(
"versioned 目录已存在,拒绝覆盖:{}",
plan.version_path.display()
));
}
fs::rename(&plan.staging_path, &plan.version_path).map_err(|error| {
format!(
"发布官方资源版本目录失败 {} -> {}{error}",
plan.staging_path.display(),
plan.version_path.display()
)
})?;
switch_current_symlink(&self.root, &self.current_path, &plan.id)?;
Ok(plan.version_path.clone())
}
}
/// Official update runner.
@@ -517,16 +729,24 @@ impl OfficialUpdateService {
};
check_shutdown_requested(&mut should_cancel)?;
let publish_layout = OfficialPublishLayout::new(&config.output_root);
let active_resource_root = publish_layout
.active_resource_root()
.map_err(anyhow::Error::msg)?;
let has_current_pointer = publish_layout
.has_current_pointer()
.map_err(anyhow::Error::msg)?;
let default_platforms = default_official_platforms();
let platforms = config
.platforms
.as_deref()
.unwrap_or(default_platforms.as_slice());
let fetcher = OfficialResourcePullService::with_curl_command(
&config.output_root,
&active_resource_root,
&config.curl_command,
);
let snapshot_path = config.effective_snapshot_path();
let snapshot_path = snapshot_path_for(config, &active_resource_root);
let bootstrap_cache_path = config.bootstrap_cache_path();
let bootstrap = if config.auto_discover {
@@ -750,7 +970,9 @@ impl OfficialUpdateService {
.map(|audit| !audit.is_clean())
.unwrap_or(false);
let initial_pull_needed = !has_local_resources;
let should_download = remote_should_download || repair_needed || initial_pull_needed;
let publish_required = !has_current_pointer;
let should_download =
remote_should_download || repair_needed || initial_pull_needed || publish_required;
progress(OfficialUpdateProgress::new(
"audit",
format!(
@@ -765,6 +987,12 @@ impl OfficialUpdateService {
should_download, repair_needed, initial_pull_needed
),
));
if publish_required {
progress(OfficialUpdateProgress::new(
"publish",
"尚未存在 current 原子发布指针;本轮会发布 versioned 目录并切换 current",
));
}
check_shutdown_requested(&mut should_cancel)?;
let mut report = OfficialUpdateReport {
update_status: if should_download {
@@ -777,6 +1005,11 @@ impl OfficialUpdateService {
bundle_version: current_snapshot.bundle_version.clone(),
addressables_root: current_snapshot.addressables_root.clone(),
platforms: platforms.to_vec(),
output_root: config.output_root.clone(),
active_resource_root: active_resource_root.clone(),
current_path: publish_layout.current_path.clone(),
published_version_path: None,
staging_path: None,
snapshot_path: snapshot_path.clone(),
previous_snapshot_present: previous_snapshot.is_some(),
decision: format!("{:?}", sync_plan.decision),
@@ -850,11 +1083,35 @@ impl OfficialUpdateService {
check_shutdown_requested(&mut should_cancel)?;
let download_url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
let publish_plan = publish_layout.plan(&current_update_snapshot);
progress(OfficialUpdateProgress::new(
"publish",
format!(
"准备 staging={} versioned={}",
publish_plan.staging_path.display(),
publish_plan.version_path.display()
),
));
publish_layout
.prepare_staging(&publish_plan)
.map_err(anyhow::Error::msg)?;
publish_layout
.seed_staging_from_active(&active_resource_root, &publish_plan.staging_path)
.map_err(anyhow::Error::msg)?;
let staging_fetcher = OfficialResourcePullService::with_curl_command(
&publish_plan.staging_path,
&config.curl_command,
);
let staging_snapshot_path = snapshot_path_for(config, &publish_plan.staging_path);
report.staging_path = Some(publish_plan.staging_path.clone());
report.snapshot_path = staging_snapshot_path.clone();
report.download_manifest = staging_fetcher.download_manifest_path();
progress(OfficialUpdateProgress::new(
"download",
format!("下载或复用 {download_url_count} 个官方 URL"),
));
let pull_report = fetcher
let pull_report = staging_fetcher
.pull_with_progress_and_cancellation(
&pull_plan,
|event| {
@@ -878,16 +1135,37 @@ impl OfficialUpdateService {
"执行最终本地 manifest 审计",
));
check_shutdown_requested(&mut should_cancel)?;
let final_audit = fetcher
let final_audit = staging_fetcher
.audit_local_manifest(&pull_plan)
.map_err(anyhow::Error::msg)?;
progress(OfficialUpdateProgress::new(
"snapshot",
format!("写入快照 {}", snapshot_path.display()),
format!("写入快照 {}", staging_snapshot_path.display()),
));
write_snapshot(&snapshot_path, &current_update_snapshot)?;
if config.snapshot_path.is_none() {
write_snapshot(&staging_snapshot_path, &current_update_snapshot)?;
}
progress(OfficialUpdateProgress::new(
"publish",
format!(
"校验完成,发布 versioned 目录并切换 current -> {}",
publish_plan.version_path.display()
),
));
let published_version_path = publish_layout
.publish(&publish_plan)
.map_err(anyhow::Error::msg)?;
let final_snapshot_path = snapshot_path_for(config, &published_version_path);
if config.snapshot_path.is_some() {
write_snapshot(&final_snapshot_path, &current_update_snapshot)?;
}
report.update_status = OfficialUpdateStatus::Downloaded;
report.active_resource_root = published_version_path.clone();
report.published_version_path = Some(published_version_path.clone());
report.snapshot_path = final_snapshot_path.clone();
report.download_manifest = published_version_path.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE);
report.resource_count = Some(pull_report.items.len());
report.downloaded_count = pull_report.downloaded_count();
report.resumed_count = pull_report.resumed_count();
@@ -903,7 +1181,7 @@ impl OfficialUpdateService {
pull_report.official_hash_verified_count(),
final_audit.zip_structure_verified_count(),
);
report.snapshot_written = Some(snapshot_path);
report.snapshot_written = Some(final_snapshot_path);
progress(OfficialUpdateProgress::new(
"finish",
@@ -1079,7 +1357,8 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new(
"download",
format!("({}/{}) 开始 {}", event.index, event.total, event.url),
),
)
.with_download_progress(&event),
OfficialResourcePullProgressKind::Finished => {
let status = event.status.map(localized_pull_status).unwrap_or("未知");
OfficialUpdateProgress::new(
@@ -1094,6 +1373,7 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
event.url
),
)
.with_download_progress(&event)
}
}
}
@@ -1143,6 +1423,195 @@ fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
Ok(())
}
fn snapshot_path_for(config: &OfficialUpdateConfig, resource_root: &Path) -> PathBuf {
config
.snapshot_path
.clone()
.unwrap_or_else(|| resource_root.join(OFFICIAL_SYNC_SNAPSHOT_FILE))
}
fn path_exists_no_follow(path: &Path) -> Result<bool, String> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
Err(format!("路径不能是 symlink{}", path.display()))
}
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(format!("检查路径失败 {}{error}", path.display())),
}
}
fn copy_tree_no_symlink(
source: &Path,
destination: &Path,
skip_publish_management_entries: bool,
) -> Result<(), String> {
ensure_safe_directory_path(source, "官方资源发布源目录")?;
ensure_safe_directory_path(destination, "官方资源 staging 目录")?;
fs::create_dir_all(destination).map_err(|error| {
format!(
"创建官方资源 staging 子目录失败 {}{error}",
destination.display()
)
})?;
for entry in fs::read_dir(source)
.map_err(|error| format!("读取官方资源发布源目录失败 {}{error}", source.display()))?
{
let entry = entry.map_err(|error| {
format!("读取官方资源发布源目录项失败 {}{error}", source.display())
})?;
let source_path = entry.path();
let file_name = entry.file_name();
if skip_publish_management_entries {
if let Some(name) = file_name.to_str() {
if matches!(
name,
OFFICIAL_STAGING_DIR
| OFFICIAL_VERSIONS_DIR
| OFFICIAL_CURRENT_LINK
| ".official-sync.lock"
) {
continue;
}
}
}
if destination.starts_with(&source_path) {
continue;
}
let destination_path = destination.join(file_name);
let metadata = fs::symlink_metadata(&source_path).map_err(|error| {
format!(
"读取官方资源发布源元数据失败 {}{error}",
source_path.display()
)
})?;
if metadata.file_type().is_symlink() {
return Err(format!(
"官方资源发布源不能包含 symlink:{}",
source_path.display()
));
}
if metadata.is_dir() {
copy_tree_no_symlink(&source_path, &destination_path, false)?;
} else if metadata.is_file() {
if let Some(name) = source_path.file_name().and_then(|value| value.to_str()) {
if name.ends_with(".part") || name.ends_with(".tmp") {
continue;
}
}
if let Err(_error) = fs::hard_link(&source_path, &destination_path) {
fs::copy(&source_path, &destination_path).map_err(|copy_error| {
format!(
"复制官方资源到 staging 失败 {} -> {}{copy_error}",
source_path.display(),
destination_path.display()
)
})?;
}
} else {
return Err(format!(
"官方资源发布源包含非普通文件:{}",
source_path.display()
));
}
}
Ok(())
}
#[cfg(unix)]
fn switch_current_symlink(
root: &Path,
current_path: &Path,
publish_id: &str,
) -> Result<(), String> {
use std::os::unix::fs::symlink;
let temporary = root.join(format!(".current.{}.tmp", std::process::id()));
if path_exists_no_follow(&temporary)? {
fs::remove_file(&temporary).map_err(|error| {
format!("清理临时 current 指针失败 {}{error}", temporary.display())
})?;
}
let relative_target = Path::new(OFFICIAL_VERSIONS_DIR).join(publish_id);
symlink(&relative_target, &temporary).map_err(|error| {
format!(
"创建临时 current 指针失败 {} -> {}{error}",
temporary.display(),
relative_target.display()
)
})?;
if let Ok(metadata) = fs::symlink_metadata(current_path) {
if !metadata.file_type().is_symlink() {
let _ = fs::remove_file(&temporary);
return Err(format!(
"current 已存在但不是 symlink{}",
current_path.display()
));
}
}
fs::rename(&temporary, current_path).map_err(|error| {
format!(
"切换 current 指针失败 {} -> {}{error}",
temporary.display(),
current_path.display()
)
})
}
#[cfg(not(unix))]
fn switch_current_symlink(
_root: &Path,
_current_path: &Path,
_publish_id: &str,
) -> Result<(), String> {
Err("原子 current symlink 发布目前只支持 Unix/Linux 平台".to_string())
}
fn publish_id(snapshot: &OfficialUpdateSnapshot) -> String {
let bundle = snapshot
.bundle_version
.as_deref()
.map(sanitize_publish_segment)
.unwrap_or_else(|| "no-bundle".to_string());
format!(
"{}-{}-{}-{}-{}",
sanitize_publish_segment(&snapshot.app_version),
bundle,
snapshot.addressables_marker_checked_count(),
unix_seconds_now(),
std::process::id()
)
}
fn sanitize_publish_segment(value: &str) -> String {
let sanitized = value
.chars()
.map(|ch| {
if ch.is_control() || matches!(ch, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
{
'_'
} else {
ch
}
})
.collect::<String>();
if sanitized.is_empty() {
"unknown".to_string()
} else {
sanitized
}
}
fn unix_seconds_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Reads an official update snapshot from disk, accepting legacy v1 snapshots.
pub fn read_snapshot(path: &Path) -> anyhow::Result<Option<OfficialUpdateSnapshot>> {
let Some(data) = read_file_no_symlink(path, "官方更新快照").map_err(anyhow::Error::msg)?