mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 00:55:15 +08:00
509 lines
17 KiB
Rust
509 lines
17 KiB
Rust
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;
|
|
const DEFAULT_ERROR_RETRY_SECONDS: u64 = 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,
|
|
error_retry_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),
|
|
error_retry_interval: Duration::from_secs(DEFAULT_ERROR_RETRY_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"));
|
|
}
|
|
if options.error_retry_interval.is_zero() {
|
|
return Err(anyhow::anyhow!(
|
|
"watch error retry interval must be greater than zero"
|
|
));
|
|
}
|
|
let service = OfficialUpdateService::new();
|
|
loop {
|
|
let mut sleep_for = options.interval;
|
|
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) => {
|
|
sleep_for = options.error_retry_interval;
|
|
let payload = ErrorReport {
|
|
status: "error",
|
|
exit_code: EXIT_ERROR,
|
|
error: error.to_string(),
|
|
next_retry_seconds: Some(sleep_for.as_secs()),
|
|
};
|
|
eprintln!("{}", serde_json::to_string_pretty(&payload)?);
|
|
}
|
|
}
|
|
|
|
thread::sleep(sleep_for);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
"--error-retry" => {
|
|
options.error_retry_interval =
|
|
parse_duration(&next_option_value(&mut args, &flag)?)?;
|
|
}
|
|
"--error-retry-seconds" => {
|
|
let seconds = next_option_value(&mut args, &flag)?
|
|
.parse::<u64>()
|
|
.map_err(|error| anyhow::anyhow!("invalid seconds for {flag}: {error}"))?;
|
|
options.error_retry_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.error_retry_interval.is_zero() {
|
|
return Err(anyhow::anyhow!(
|
|
"watch error retry 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] [--error-retry 60s] [--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_eq!(
|
|
options.error_retry_interval,
|
|
Duration::from_secs(DEFAULT_ERROR_RETRY_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_eq!(
|
|
options.error_retry_interval,
|
|
Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS)
|
|
);
|
|
assert!(options.quiet_up_to_date);
|
|
assert!(!options.quiet_up_to_date_explicit);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_explicit_watch_error_retry_interval() {
|
|
let options = parse(&[
|
|
"bat-official-sync",
|
|
"--watch",
|
|
"--interval",
|
|
"1h",
|
|
"--error-retry",
|
|
"45s",
|
|
])
|
|
.unwrap();
|
|
assert_eq!(options.interval, Duration::from_secs(3600));
|
|
assert_eq!(options.error_retry_interval, Duration::from_secs(45));
|
|
|
|
let options = parse(&[
|
|
"bat-official-sync",
|
|
"--watch",
|
|
"--error-retry-seconds",
|
|
"30",
|
|
])
|
|
.unwrap();
|
|
assert_eq!(options.error_retry_interval, Duration::from_secs(30));
|
|
}
|
|
|
|
#[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"));
|
|
|
|
let error =
|
|
parse(&["bat-official-sync", "--watch", "--error-retry-seconds", "0"]).unwrap_err();
|
|
assert!(error.to_string().contains("error retry interval"));
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|