feat: track official versions and import resources

Persist official version-state, extend CAS/ResourceRepository import classification, and add offline catalog and failure regression fixtures.

Fixes #13

Fixes #12

Fixes #8
This commit is contained in:
2026-07-15 19:53:10 +08:00
parent 4192d7ee4c
commit 90307a3243
30 changed files with 1269 additions and 157 deletions
+93 -76
View File
@@ -1,9 +1,9 @@
use bat_adapters::official::yostar_jp::PatchPlatform;
use bat_infrastructure::{
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,
lexical_absolute, open_append_file, read_file_no_symlink, read_version_state,
validate_output_root, validate_runtime_state_dir, write_file_atomic, OfficialServerInfoSource,
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionState, PRIVATE_FILE_MODE,
};
use serde::{Deserialize, Serialize};
use std::env;
@@ -608,17 +608,7 @@ fn record_daemon_status(
return;
}
if let Err(error) = update_daemon_status(
state_dir,
update.state,
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,
) {
if let Err(error) = update_daemon_status(state_dir, update) {
logger.log_text("daemon", format!("更新后台状态失败:{error}"));
}
}
@@ -731,10 +721,7 @@ fn classify_pid_lock_file(path: &Path) -> anyhow::Result<PidLockState> {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Ok(PidLockState::Corrupt);
}
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) => return Err(error.into()),
};
let contents = fs::read_to_string(path)?;
let Some(pid) = parse_pid_value(&contents) else {
return Ok(PidLockState::Corrupt);
};
@@ -1322,6 +1309,8 @@ struct DaemonStatusReport {
current_stage: 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>>,
@@ -1600,6 +1589,14 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
.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" },
@@ -1659,6 +1656,8 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
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),
@@ -1896,6 +1895,8 @@ fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
print_json_field(value, "current_stage", "当前阶段");
print_json_field(value, "current_message", "当前消息");
print_json_field(value, "download_progress", "下载进度");
print_json_field(value, "version_state_path", "版本状态路径");
print_json_field(value, "version_state", "版本状态");
print_json_field(value, "resource_output_root", "资源目录");
print_json_field(value, "state_dir", "状态目录");
print_json_field(value, "socket_path", "socket");
@@ -2102,6 +2103,7 @@ impl HumanReport for OfficialUpdateReport {
print_path_field("输出根目录", &self.output_root);
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);
@@ -2174,7 +2176,32 @@ impl HumanReport for DaemonStatusReport {
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_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()),
);
print_field("失败版本数", version_state.failed_versions.len());
}
print_optional_path_field("资源目录", self.resource_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());
@@ -2499,29 +2526,26 @@ struct DoctorReport {
}
fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
let mut checks = Vec::new();
checks.push(path_check(
"state_dir",
&options.state_dir,
"后台状态目录可用",
));
checks.push(path_check(
"output_root",
&options.config.output_root,
"资源输出目录可用",
));
checks.push(safety_check(
"output_root_safety",
validate_output_root(&options.config.output_root),
"资源输出目录安全边界通过",
));
checks.push(safety_check(
"state_dir_safety",
validate_runtime_state_dir(&options.state_dir),
"后台状态目录安全边界通过",
));
checks.push(command_check("curl", &options.config.curl_command));
checks.push(command_check("unzip", &options.config.unzip_command));
let mut checks = vec![
path_check("state_dir", &options.state_dir, "后台状态目录可用"),
path_check(
"output_root",
&options.config.output_root,
"资源输出目录可用",
),
safety_check(
"output_root_safety",
validate_output_root(&options.config.output_root),
"资源输出目录安全边界通过",
),
safety_check(
"state_dir_safety",
validate_runtime_state_dir(&options.state_dir),
"后台状态目录安全边界通过",
),
command_check("curl", &options.config.curl_command),
command_check("unzip", &options.config.unzip_command),
];
let pid_path = daemon_pid_path(&options.state_dir);
let daemon_running = read_pid_file(&pid_path)
@@ -2864,17 +2888,7 @@ fn write_daemon_status_file(path: &Path, status: &DaemonStatusFile) -> anyhow::R
Ok(())
}
fn update_daemon_status(
state_dir: &Path,
state: &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,
) -> anyhow::Result<()> {
fn update_daemon_status(state_dir: &Path, update: DaemonStatusUpdate<'_>) -> anyhow::Result<()> {
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,
@@ -2899,22 +2913,23 @@ fn update_daemon_status(
command: env::args().collect(),
});
status.pid = std::process::id();
status.state = state.to_string();
status.state = update.state.to_string();
status.updated_unix_seconds = unix_seconds_now();
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.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 = next_check_unix_seconds;
if state != "running" {
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 = pending_scheduled_force;
status.next_forced_refresh_unix_seconds = system_time_to_unix_seconds(next_forced_refresh_at);
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)
}
@@ -3130,11 +3145,11 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
fn format_duration_arg(duration: Duration) -> String {
let millis = duration.as_millis();
if millis % 3_600_000 == 0 {
if millis.is_multiple_of(3_600_000) {
format!("{}h", millis / 3_600_000)
} else if millis % 60_000 == 0 {
} else if millis.is_multiple_of(60_000) {
format!("{}m", millis / 60_000)
} else if millis % 1_000 == 0 {
} else if millis.is_multiple_of(1_000) {
format!("{}s", millis / 1_000)
} else {
format!("{millis}ms")
@@ -3825,7 +3840,7 @@ fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result
"verify 只验证本地资源,不能和 --force 同时使用"
));
}
if options.config.repair == false && options.config.audit_local == false {
if !options.config.repair && !options.config.audit_local {
return Err(anyhow::anyhow!(
"verify 必须启用本地审计;不要同时传 --no-repair --no-audit-local"
));
@@ -3854,7 +3869,7 @@ fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()>
}
fn tools_are_non_default(config: &OfficialUpdateConfig) -> bool {
config.curl_command != PathBuf::from("curl") || config.unzip_command != PathBuf::from("unzip")
config.curl_command != Path::new("curl") || config.unzip_command != Path::new("unzip")
}
fn next_option_value(
@@ -4503,14 +4518,16 @@ mod tests {
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),
DaemonStatusUpdate {
state: "sleeping",
last_update_status: Some("downloaded".to_string()),
last_error: None,
next_retry_seconds: Some(30),
last_success_unix_seconds: Some(123456),
next_check_unix_seconds: Some(next_check),
pending_scheduled_force: false,
next_forced_refresh_at: SystemTime::now() + Duration::from_secs(3600),
},
)
.unwrap();
let report = build_daemon_status_report(&state_dir).unwrap();