mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 06:56:44 +08:00
feat: add official resource sync pipeline
Add the production-facing official update service and bat-official-sync watch CLI for unattended resource synchronization. Support launcher-resource discovery without installing the launcher, remote marker snapshots, local manifest audit and repair, official seed hash validation, bootstrap caching, richer Addressables coverage, SQLite resource persistence, and FFI JSON helpers.
This commit is contained in:
@@ -0,0 +1,447 @@
|
||||
use bat_adapters::official::yostar_jp::PatchPlatform;
|
||||
use bat_infrastructure::{
|
||||
OfficialServerInfoSource, OfficialUpdateConfig, OfficialUpdateService, OfficialUpdateStatus,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const EXIT_ERROR: i32 = 1;
|
||||
const EXIT_LOCKED: i32 = 75;
|
||||
const DEFAULT_WATCH_INTERVAL_SECONDS: u64 = 60 * 60;
|
||||
|
||||
fn main() {
|
||||
match run() {
|
||||
Ok(()) => {}
|
||||
Err(error) => {
|
||||
let exit_code = if error.to_string().contains("locked") {
|
||||
EXIT_LOCKED
|
||||
} else {
|
||||
EXIT_ERROR
|
||||
};
|
||||
let payload = ErrorReport {
|
||||
status: "error",
|
||||
exit_code,
|
||||
error: error.to_string(),
|
||||
next_retry_seconds: None,
|
||||
};
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&payload)
|
||||
.unwrap_or_else(|_| "{\"status\":\"error\"}".to_string())
|
||||
);
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> anyhow::Result<()> {
|
||||
let options = parse_args()?;
|
||||
if options.watch {
|
||||
run_watch(options)?;
|
||||
} else {
|
||||
let report = OfficialUpdateService::new().run(&options.config)?;
|
||||
if should_print_status(report.update_status, options.quiet_up_to_date) {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ErrorReport<'a> {
|
||||
status: &'a str,
|
||||
exit_code: i32,
|
||||
error: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
next_retry_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct CliOptions {
|
||||
config: OfficialUpdateConfig,
|
||||
watch: bool,
|
||||
interval: Duration,
|
||||
quiet_up_to_date: bool,
|
||||
quiet_up_to_date_explicit: bool,
|
||||
}
|
||||
|
||||
impl Default for CliOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
config: OfficialUpdateConfig::default(),
|
||||
watch: false,
|
||||
interval: Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS),
|
||||
quiet_up_to_date: false,
|
||||
quiet_up_to_date_explicit: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
||||
if options.config.dry_run {
|
||||
return Err(anyhow::anyhow!("watch mode cannot be used with --dry-run"));
|
||||
}
|
||||
if options.interval.is_zero() {
|
||||
return Err(anyhow::anyhow!("watch interval must be greater than zero"));
|
||||
}
|
||||
let service = OfficialUpdateService::new();
|
||||
loop {
|
||||
match service.run(&options.config) {
|
||||
Ok(report) => {
|
||||
if should_print_status(report.update_status, options.quiet_up_to_date) {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let payload = ErrorReport {
|
||||
status: "error",
|
||||
exit_code: EXIT_ERROR,
|
||||
error: error.to_string(),
|
||||
next_retry_seconds: Some(options.interval.as_secs()),
|
||||
};
|
||||
eprintln!("{}", serde_json::to_string_pretty(&payload)?);
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(options.interval);
|
||||
}
|
||||
}
|
||||
|
||||
fn should_print_status(status: OfficialUpdateStatus, quiet_up_to_date: bool) -> bool {
|
||||
!(quiet_up_to_date && status == OfficialUpdateStatus::UpToDate)
|
||||
}
|
||||
|
||||
fn parse_args() -> anyhow::Result<CliOptions> {
|
||||
parse_args_from(env::args())
|
||||
}
|
||||
|
||||
fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result<CliOptions> {
|
||||
let mut args = raw_args.into_iter();
|
||||
let binary = args
|
||||
.next()
|
||||
.unwrap_or_else(|| "bat-official-sync".to_string());
|
||||
let mut options = CliOptions::default();
|
||||
|
||||
while let Some(flag) = args.next() {
|
||||
match flag.as_str() {
|
||||
"--auto-discover" => {
|
||||
options.config.auto_discover = true;
|
||||
}
|
||||
"--server-info-url" => {
|
||||
options.config.server_info_source = Some(OfficialServerInfoSource::OfficialUrl(
|
||||
next_option_value(&mut args, &flag)?,
|
||||
));
|
||||
}
|
||||
"--server-info-file" => {
|
||||
options.config.server_info_source = Some(OfficialServerInfoSource::OfficialFile(
|
||||
next_option_value(&mut args, &flag)?,
|
||||
));
|
||||
}
|
||||
"--server-info-path" => {
|
||||
options.config.server_info_source = Some(OfficialServerInfoSource::LocalPath(
|
||||
PathBuf::from(next_option_value(&mut args, &flag)?),
|
||||
));
|
||||
}
|
||||
"--connection-group" => {
|
||||
options.config.connection_group = Some(next_option_value(&mut args, &flag)?);
|
||||
}
|
||||
"--app-version" => {
|
||||
options.config.app_version = Some(next_option_value(&mut args, &flag)?);
|
||||
}
|
||||
"--launcher-version" => {
|
||||
options.config.launcher_version = next_option_value(&mut args, &flag)?;
|
||||
}
|
||||
"--platforms" => {
|
||||
let value = next_option_value(&mut args, &flag)?;
|
||||
options.config.platforms =
|
||||
Some(parse_platforms(&value).map_err(anyhow::Error::msg)?);
|
||||
}
|
||||
"--output" => {
|
||||
options.config.output_root = PathBuf::from(next_option_value(&mut args, &flag)?);
|
||||
}
|
||||
"--snapshot" => {
|
||||
options.config.snapshot_path =
|
||||
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
|
||||
}
|
||||
"--curl" => {
|
||||
options.config.curl_command = PathBuf::from(next_option_value(&mut args, &flag)?);
|
||||
}
|
||||
"--unzip" => {
|
||||
options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?);
|
||||
}
|
||||
"--dry-run" => {
|
||||
options.config.dry_run = true;
|
||||
}
|
||||
"--plan" => {
|
||||
options.config.plan = true;
|
||||
}
|
||||
"--force" => {
|
||||
options.config.force = true;
|
||||
}
|
||||
"--audit-local" => {
|
||||
options.config.audit_local = true;
|
||||
}
|
||||
"--no-audit-local" => {
|
||||
options.config.audit_local = false;
|
||||
}
|
||||
"--repair" => {
|
||||
options.config.repair = true;
|
||||
}
|
||||
"--no-repair" => {
|
||||
options.config.repair = false;
|
||||
}
|
||||
"--watch" => {
|
||||
options.watch = true;
|
||||
}
|
||||
"--interval" => {
|
||||
options.interval = parse_duration(&next_option_value(&mut args, &flag)?)?;
|
||||
}
|
||||
"--interval-seconds" => {
|
||||
let seconds = next_option_value(&mut args, &flag)?
|
||||
.parse::<u64>()
|
||||
.map_err(|error| anyhow::anyhow!("invalid seconds for {flag}: {error}"))?;
|
||||
options.interval = Duration::from_secs(seconds);
|
||||
}
|
||||
"--quiet-up-to-date" => {
|
||||
options.quiet_up_to_date = true;
|
||||
options.quiet_up_to_date_explicit = true;
|
||||
}
|
||||
"--no-quiet-up-to-date" => {
|
||||
options.quiet_up_to_date = false;
|
||||
options.quiet_up_to_date_explicit = true;
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage(&binary);
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!("unknown argument: {flag}")),
|
||||
}
|
||||
}
|
||||
|
||||
if options.watch && options.config.dry_run {
|
||||
return Err(anyhow::anyhow!("watch mode cannot be used with --dry-run"));
|
||||
}
|
||||
if options.interval.is_zero() {
|
||||
return Err(anyhow::anyhow!("watch interval must be greater than zero"));
|
||||
}
|
||||
if options.watch && !options.quiet_up_to_date_explicit {
|
||||
options.quiet_up_to_date = true;
|
||||
}
|
||||
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
fn next_option_value(
|
||||
args: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
args.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing value for {flag}"))
|
||||
}
|
||||
|
||||
fn print_usage(binary: &str) {
|
||||
eprintln!(
|
||||
"usage: {binary} [--auto-discover | --server-info-url <official-url> | --server-info-file <official-json-name> | --server-info-path <local-json>] \
|
||||
[--app-version 1.70.0] [--connection-group <name>] [--launcher-version 1.7.2] \
|
||||
[--platforms Windows,Android] [--output official_update_output] [--snapshot official-sync-snapshot.json] \
|
||||
[--curl curl] [--unzip unzip] [--dry-run] [--plan] [--force] [--audit-local|--no-audit-local] [--repair|--no-repair] \
|
||||
[--watch] [--interval 1h|60m|3600s] [--quiet-up-to-date|--no-quiet-up-to-date]"
|
||||
);
|
||||
}
|
||||
|
||||
fn parse_duration(value: &str) -> anyhow::Result<Duration> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err(anyhow::anyhow!("duration must not be empty"));
|
||||
}
|
||||
|
||||
let (number, multiplier) = if let Some(number) = value.strip_suffix("ms") {
|
||||
let millis = number
|
||||
.parse::<u64>()
|
||||
.map_err(|error| anyhow::anyhow!("invalid millisecond duration {value}: {error}"))?;
|
||||
return Ok(Duration::from_millis(millis));
|
||||
} else if let Some(number) = value.strip_suffix('s') {
|
||||
(number, 1)
|
||||
} else if let Some(number) = value.strip_suffix('m') {
|
||||
(number, 60)
|
||||
} else if let Some(number) = value.strip_suffix('h') {
|
||||
(number, 60 * 60)
|
||||
} else {
|
||||
(value, 1)
|
||||
};
|
||||
|
||||
let amount = number
|
||||
.parse::<u64>()
|
||||
.map_err(|error| anyhow::anyhow!("invalid duration {value}: {error}"))?;
|
||||
Ok(Duration::from_secs(amount.saturating_mul(multiplier)))
|
||||
}
|
||||
|
||||
fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(parse_platform)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_platform(value: &str) -> Result<PatchPlatform, String> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"windows" | "win" => Ok(PatchPlatform::Windows),
|
||||
"android" => Ok(PatchPlatform::Android),
|
||||
_ => Err(format!("unsupported platform: {value}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(values: &[&str]) -> anyhow::Result<CliOptions> {
|
||||
parse_args_from(values.iter().map(|value| value.to_string()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_auto_discover_sync_args() {
|
||||
let options = parse(&[
|
||||
"bat-official-sync",
|
||||
"--auto-discover",
|
||||
"--platforms",
|
||||
"Windows,Android",
|
||||
"--snapshot",
|
||||
"/tmp/snapshot.json",
|
||||
"--dry-run",
|
||||
"--plan",
|
||||
])
|
||||
.unwrap();
|
||||
let config = options.config;
|
||||
|
||||
assert!(config.auto_discover);
|
||||
assert_eq!(
|
||||
config.platforms.as_deref(),
|
||||
Some([PatchPlatform::Windows, PatchPlatform::Android].as_slice())
|
||||
);
|
||||
assert_eq!(
|
||||
config.snapshot_path,
|
||||
Some(PathBuf::from("/tmp/snapshot.json"))
|
||||
);
|
||||
assert!(config.dry_run);
|
||||
assert!(config.plan);
|
||||
assert!(config.audit_local);
|
||||
assert!(config.repair);
|
||||
assert!(!options.watch);
|
||||
assert_eq!(
|
||||
options.interval,
|
||||
Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS)
|
||||
);
|
||||
assert!(!options.quiet_up_to_date);
|
||||
assert!(!options.quiet_up_to_date_explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_explicit_source_and_disable_repair() {
|
||||
let options = parse(&[
|
||||
"bat-official-sync",
|
||||
"--server-info-url",
|
||||
"https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json",
|
||||
"--connection-group",
|
||||
"Prod-Audit",
|
||||
"--app-version",
|
||||
"1.70.0",
|
||||
"--no-audit-local",
|
||||
"--no-repair",
|
||||
"--unzip",
|
||||
"/usr/bin/unzip",
|
||||
])
|
||||
.unwrap();
|
||||
let config = options.config;
|
||||
|
||||
assert!(!config.auto_discover);
|
||||
assert_eq!(config.connection_group.as_deref(), Some("Prod-Audit"));
|
||||
assert_eq!(config.app_version.as_deref(), Some("1.70.0"));
|
||||
assert!(!config.audit_local);
|
||||
assert!(!config.repair);
|
||||
assert_eq!(config.unzip_command, PathBuf::from("/usr/bin/unzip"));
|
||||
assert!(matches!(
|
||||
config.server_info_source,
|
||||
Some(OfficialServerInfoSource::OfficialUrl(ref url))
|
||||
if url == "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_watch_defaults_to_one_hour_and_quiet_up_to_date() {
|
||||
let options = parse(&[
|
||||
"bat-official-sync",
|
||||
"--auto-discover",
|
||||
"--watch",
|
||||
"--interval",
|
||||
"30m",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert!(options.watch);
|
||||
assert_eq!(options.interval, Duration::from_secs(30 * 60));
|
||||
assert!(options.quiet_up_to_date);
|
||||
assert!(!options.quiet_up_to_date_explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_no_quiet_up_to_date_overrides_watch_default() {
|
||||
let options = parse(&[
|
||||
"bat-official-sync",
|
||||
"--watch",
|
||||
"--no-quiet-up-to-date",
|
||||
"--interval",
|
||||
"1h",
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert!(options.watch);
|
||||
assert!(!options.quiet_up_to_date);
|
||||
assert!(options.quiet_up_to_date_explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_watch_dry_run_and_zero_interval() {
|
||||
let error = parse(&["bat-official-sync", "--watch", "--dry-run"]).unwrap_err();
|
||||
assert!(error.to_string().contains("watch mode"));
|
||||
|
||||
let error =
|
||||
parse(&["bat-official-sync", "--watch", "--interval-seconds", "0"]).unwrap_err();
|
||||
assert!(error.to_string().contains("greater than zero"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_duration_units() {
|
||||
assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
|
||||
assert_eq!(parse_duration("60m").unwrap(), Duration::from_secs(3600));
|
||||
assert_eq!(parse_duration("90s").unwrap(), Duration::from_secs(90));
|
||||
assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250));
|
||||
assert_eq!(parse_duration("3600").unwrap(), Duration::from_secs(3600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_up_to_date_suppresses_only_clean_reports() {
|
||||
assert!(!should_print_status(OfficialUpdateStatus::UpToDate, true));
|
||||
assert!(should_print_status(OfficialUpdateStatus::Downloaded, true));
|
||||
assert!(should_print_status(
|
||||
OfficialUpdateStatus::WouldDownload,
|
||||
true
|
||||
));
|
||||
assert!(should_print_status(OfficialUpdateStatus::UpToDate, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_string_is_stable() {
|
||||
assert_eq!(OfficialUpdateStatus::UpToDate.as_str(), "up_to_date");
|
||||
assert_eq!(
|
||||
OfficialUpdateStatus::WouldDownload.as_str(),
|
||||
"would_download"
|
||||
);
|
||||
assert_eq!(OfficialUpdateStatus::Downloaded.as_str(), "downloaded");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user