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");
|
||||
}
|
||||
}
|
||||
@@ -276,12 +276,16 @@ mod tests {
|
||||
hash: "synthetic-manifest-hash".to_string(),
|
||||
size: 99,
|
||||
resource_type: ResourceType::AssetBundle,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
},
|
||||
ResourceEntry {
|
||||
path: "synthetic/catalog.json".to_string(),
|
||||
hash: "synthetic-catalog-hash".to_string(),
|
||||
size: 1,
|
||||
resource_type: ResourceType::Manifest,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
},
|
||||
],
|
||||
metadata: ManifestMetadata {
|
||||
|
||||
@@ -12,17 +12,20 @@
|
||||
|
||||
pub mod cas;
|
||||
pub mod import;
|
||||
pub mod official_game_main_config;
|
||||
pub mod official_download;
|
||||
pub mod official_game_main_config;
|
||||
pub mod official_launcher;
|
||||
pub mod official_pull;
|
||||
pub mod official_sync;
|
||||
pub mod official_update;
|
||||
pub mod resources;
|
||||
|
||||
pub use cas::FileSystemCasRepository;
|
||||
pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService};
|
||||
pub use official_download::{
|
||||
OfficialResourcePullItem, OfficialResourcePullReport, OfficialResourcePullService,
|
||||
OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport,
|
||||
OfficialLocalManifestAuditStatus, OfficialResourcePullItem, OfficialResourcePullReport,
|
||||
OfficialResourcePullService,
|
||||
};
|
||||
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
|
||||
pub use official_launcher::{
|
||||
@@ -39,7 +42,15 @@ pub use official_sync::{
|
||||
build_official_sync_plan, changed_endpoint_urls, classify_sync_decision,
|
||||
default_official_platforms, OfficialSyncDecision, OfficialSyncPlan,
|
||||
};
|
||||
pub use resources::InMemoryResourceRepository;
|
||||
pub use official_update::{
|
||||
cached_game_main_config_for_metadata, diff_extended_snapshot, read_bootstrap_cache,
|
||||
read_snapshot, write_bootstrap_cache, write_snapshot, ExtendedSnapshotDelta,
|
||||
GameMainConfigSnapshot, LauncherMetadataSnapshot, OfficialBootstrapCache,
|
||||
OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, OfficialServerInfoSource,
|
||||
OfficialUpdateConfig, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot,
|
||||
OfficialUpdateStatus, ResolvedBootstrap,
|
||||
};
|
||||
pub use resources::{InMemoryResourceRepository, SqliteResourceRepository};
|
||||
|
||||
/// Infrastructure 版本号
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,13 @@
|
||||
//! Official launcher package bootstrap for `GameMainConfig`.
|
||||
//!
|
||||
//! This is an explicit metadata discovery helper. It downloads official HTTP
|
||||
//! artifacts into a temporary directory, extracts only the data needed to read
|
||||
//! `GameMainConfig`, and does not install or execute the official launcher.
|
||||
|
||||
use crate::official_launcher::OfficialLauncherBootstrapService;
|
||||
use crate::official_launcher::launcher_package_url;
|
||||
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
|
||||
use crate::official_launcher::OfficialLauncherBootstrapService;
|
||||
use crate::official_launcher::{YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig};
|
||||
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
@@ -72,14 +76,15 @@ impl OfficialGameMainConfigBootstrapService {
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "Official launcher remote manifest has no source".to_string())?;
|
||||
let cdn_config = self.launcher.fetch_cdn_config()?;
|
||||
let game_zip_url = launcher_package_url(&cdn_config.primary_cdn, &manifest_source)?;
|
||||
let package_path = select_game_package_path(&game_config, &manifest_source)?;
|
||||
let game_zip_url = launcher_package_url(&cdn_config.primary_cdn, package_path)?;
|
||||
let temp_dir = TempDir::new()
|
||||
.map_err(|error| format!("Failed to create temporary directory: {error}"))?;
|
||||
let archive_path = temp_dir.path().join("official-game.zip");
|
||||
self.download_file_with_fallback(
|
||||
&game_zip_url,
|
||||
&cdn_config.back_up_cdn,
|
||||
&manifest_source,
|
||||
package_path,
|
||||
&archive_path,
|
||||
)?;
|
||||
let extract_root = temp_dir.path().join("extract");
|
||||
@@ -104,7 +109,8 @@ impl OfficialGameMainConfigBootstrapService {
|
||||
/// Fetches the latest official game ZIP, extracts `resources.assets`, and
|
||||
/// decrypts `GameMainConfig`.
|
||||
pub fn fetch_game_main_config(&self) -> Result<YostarJpGameMainConfig, String> {
|
||||
self.fetch_bootstrap().map(|bootstrap| bootstrap.game_main_config)
|
||||
self.fetch_bootstrap()
|
||||
.map(|bootstrap| bootstrap.game_main_config)
|
||||
}
|
||||
|
||||
fn download_file(&self, url: &str, destination: &Path) -> Result<(), String> {
|
||||
@@ -180,6 +186,36 @@ impl OfficialGameMainConfigBootstrapService {
|
||||
}
|
||||
}
|
||||
|
||||
fn select_game_package_path<'a>(
|
||||
game_config: &'a YostarJpLauncherGameConfig,
|
||||
manifest_source: &'a str,
|
||||
) -> Result<&'a str, String> {
|
||||
if is_launcher_archive_path(manifest_source) {
|
||||
return Ok(manifest_source);
|
||||
}
|
||||
|
||||
if is_launcher_archive_path(&game_config.game_latest_file_path) {
|
||||
return Ok(&game_config.game_latest_file_path);
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Official launcher metadata has no usable game archive path: source={manifest_source}, game_latest_file_path={}",
|
||||
game_config.game_latest_file_path
|
||||
))
|
||||
}
|
||||
|
||||
fn is_launcher_archive_path(path: &str) -> bool {
|
||||
!path.is_empty()
|
||||
&& !path.starts_with('/')
|
||||
&& !path.starts_with('\\')
|
||||
&& path.ends_with(".zip")
|
||||
&& !path.contains('?')
|
||||
&& !path.contains('#')
|
||||
&& !path.split('/').any(|segment| {
|
||||
segment.is_empty() || segment == "." || segment == ".." || segment.contains('\\')
|
||||
})
|
||||
}
|
||||
|
||||
fn find_resources_assets(root: &Path) -> Option<PathBuf> {
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,13 @@
|
||||
//! 内存资源仓储实现。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bat_core::domain::Resource;
|
||||
use bat_core::domain::{Resource, ResourceEntry, ResourceType};
|
||||
use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteQueryResult};
|
||||
use sqlx::{QueryBuilder, Sqlite, SqlitePool};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// 以内存 `HashMap` 保存资源索引的仓储实现。
|
||||
@@ -83,6 +87,359 @@ impl ResourceRepository for InMemoryResourceRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// SQLite 资源仓储实现。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SqliteResourceRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteResourceRepository {
|
||||
/// 打开或创建 SQLite 资源仓储。
|
||||
pub async fn new(path: impl AsRef<Path>) -> bat_core::Result<Self> {
|
||||
if let Some(parent) = path.as_ref().parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
let options =
|
||||
SqliteConnectOptions::from_str(&format!("sqlite://{}", path.as_ref().display()))
|
||||
.map_err(|error| bat_core::Error::Other(error.into()))?
|
||||
.create_if_missing(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.map_err(|error| bat_core::Error::Other(error.into()))?;
|
||||
|
||||
let repository = Self { pool };
|
||||
repository.init_schema().await?;
|
||||
Ok(repository)
|
||||
}
|
||||
|
||||
async fn init_schema(&self) -> bat_core::Result<()> {
|
||||
Self::execute_query(
|
||||
&self.pool,
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS resources (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
size INTEGER NOT NULL CHECK(size >= 0),
|
||||
resource_type TEXT NOT NULL,
|
||||
local_path TEXT NOT NULL,
|
||||
address TEXT,
|
||||
dependencies_json TEXT NOT NULL DEFAULT '[]'
|
||||
)
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Self::execute_query(
|
||||
&self.pool,
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_hash
|
||||
ON resources(hash)
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Self::execute_query(
|
||||
&self.pool,
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_type
|
||||
ON resources(resource_type)
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Self::execute_query(
|
||||
&self.pool,
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_path
|
||||
ON resources(path)
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_query<'q>(
|
||||
pool: &SqlitePool,
|
||||
query: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>,
|
||||
) -> bat_core::Result<SqliteQueryResult> {
|
||||
query
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|error| bat_core::Error::Other(error.into()))
|
||||
}
|
||||
|
||||
fn resource_type_to_str(resource_type: ResourceType) -> &'static str {
|
||||
match resource_type {
|
||||
ResourceType::AssetBundle => "AssetBundle",
|
||||
ResourceType::Manifest => "Manifest",
|
||||
ResourceType::TableBundle => "TableBundle",
|
||||
ResourceType::Other => "Other",
|
||||
}
|
||||
}
|
||||
|
||||
fn resource_type_from_str(value: &str) -> bat_core::Result<ResourceType> {
|
||||
match value {
|
||||
"AssetBundle" => Ok(ResourceType::AssetBundle),
|
||||
"Manifest" => Ok(ResourceType::Manifest),
|
||||
"TableBundle" => Ok(ResourceType::TableBundle),
|
||||
"Other" => Ok(ResourceType::Other),
|
||||
other => Err(bat_core::Error::Serialization(format!(
|
||||
"Unknown resource type: {}",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn dependencies_to_json(dependencies: &[String]) -> bat_core::Result<String> {
|
||||
serde_json::to_string(dependencies)
|
||||
.map_err(|error| bat_core::Error::Serialization(error.to_string()))
|
||||
}
|
||||
|
||||
fn dependencies_from_json(value: &str) -> bat_core::Result<Vec<String>> {
|
||||
serde_json::from_str(value)
|
||||
.map_err(|error| bat_core::Error::Serialization(error.to_string()))
|
||||
}
|
||||
|
||||
fn resource_from_row(row: ResourceRow) -> bat_core::Result<Resource> {
|
||||
let (id, path, hash, size, resource_type, local_path, address, dependencies_json) = row;
|
||||
Ok(Resource {
|
||||
id,
|
||||
local_path: PathBuf::from(local_path),
|
||||
entry: ResourceEntry {
|
||||
path,
|
||||
hash,
|
||||
size: size as u64,
|
||||
resource_type: Self::resource_type_from_str(&resource_type)?,
|
||||
address,
|
||||
dependencies: Self::dependencies_from_json(&dependencies_json)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_filters<'a>(
|
||||
builder: &mut QueryBuilder<'a, Sqlite>,
|
||||
query: &'a ResourceQuery,
|
||||
) -> bat_core::Result<()> {
|
||||
let mut has_where = false;
|
||||
|
||||
if let Some(resource_type) = query.resource_type {
|
||||
push_condition_prefix(builder, &mut has_where);
|
||||
builder.push("resource_type = ");
|
||||
builder.push_bind(Self::resource_type_to_str(resource_type));
|
||||
}
|
||||
|
||||
if let Some(hash) = &query.hash {
|
||||
push_condition_prefix(builder, &mut has_where);
|
||||
builder.push("hash = ");
|
||||
builder.push_bind(hash);
|
||||
}
|
||||
|
||||
if let Some(pattern) = &query.path_pattern {
|
||||
push_condition_prefix(builder, &mut has_where);
|
||||
builder.push("path LIKE ");
|
||||
builder
|
||||
.push_bind(glob_to_like(pattern))
|
||||
.push(" ESCAPE '\\'");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_resources(
|
||||
&self,
|
||||
query: &ResourceQuery,
|
||||
limit: Option<usize>,
|
||||
) -> bat_core::Result<Vec<Resource>> {
|
||||
let mut builder = QueryBuilder::<Sqlite>::new(
|
||||
"SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json FROM resources",
|
||||
);
|
||||
Self::apply_filters(&mut builder, query)?;
|
||||
builder.push(" ORDER BY id");
|
||||
if let Some(limit) = limit {
|
||||
builder.push(" LIMIT ").push_bind(limit as i64);
|
||||
}
|
||||
|
||||
let rows: Vec<ResourceRow> = builder
|
||||
.build_query_as()
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|error| bat_core::Error::Other(error.into()))?;
|
||||
|
||||
rows.into_iter().map(Self::resource_from_row).collect()
|
||||
}
|
||||
|
||||
async fn count_resources(&self, query: &ResourceQuery) -> bat_core::Result<u64> {
|
||||
let mut builder = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM resources");
|
||||
Self::apply_filters(&mut builder, query)?;
|
||||
|
||||
let count: i64 = builder
|
||||
.build_query_scalar()
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|error| bat_core::Error::Other(error.into()))?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ResourceRepository for SqliteResourceRepository {
|
||||
async fn add(&self, resource: Resource) -> bat_core::Result<String> {
|
||||
let dependencies = Self::dependencies_to_json(&resource.entry.dependencies)?;
|
||||
Self::execute_query(
|
||||
&self.pool,
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO resources (
|
||||
id, path, hash, size, resource_type, local_path, address, dependencies_json
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
path = excluded.path,
|
||||
hash = excluded.hash,
|
||||
size = excluded.size,
|
||||
resource_type = excluded.resource_type,
|
||||
local_path = excluded.local_path,
|
||||
address = excluded.address,
|
||||
dependencies_json = excluded.dependencies_json
|
||||
"#,
|
||||
)
|
||||
.bind(resource.id.clone())
|
||||
.bind(resource.entry.path.clone())
|
||||
.bind(resource.entry.hash.clone())
|
||||
.bind(resource.entry.size as i64)
|
||||
.bind(Self::resource_type_to_str(resource.entry.resource_type))
|
||||
.bind(resource.local_path.to_string_lossy().to_string())
|
||||
.bind(resource.entry.address.clone())
|
||||
.bind(dependencies),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(resource.id)
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: &str) -> bat_core::Result<Resource> {
|
||||
let row: Option<ResourceRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json
|
||||
FROM resources
|
||||
WHERE id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|error| bat_core::Error::Other(error.into()))?;
|
||||
|
||||
row.map(Self::resource_from_row)
|
||||
.transpose()?
|
||||
.ok_or_else(|| bat_core::Error::NotFound(id.to_string()))
|
||||
}
|
||||
|
||||
async fn find_by_hash(&self, hash: &str) -> bat_core::Result<Resource> {
|
||||
let row: Option<ResourceRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json
|
||||
FROM resources
|
||||
WHERE hash = ?1
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|error| bat_core::Error::Other(error.into()))?;
|
||||
|
||||
row.map(Self::resource_from_row)
|
||||
.transpose()?
|
||||
.ok_or_else(|| bat_core::Error::NotFound(hash.to_string()))
|
||||
}
|
||||
|
||||
async fn list(&self, query: ResourceQuery) -> bat_core::Result<Vec<Resource>> {
|
||||
self.fetch_resources(&query, None).await
|
||||
}
|
||||
|
||||
async fn update(&self, resource: Resource) -> bat_core::Result<()> {
|
||||
let _existing = self.find_by_id(&resource.id).await?;
|
||||
self.add(resource).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &str) -> bat_core::Result<()> {
|
||||
let result = Self::execute_query(
|
||||
&self.pool,
|
||||
sqlx::query("DELETE FROM resources WHERE id = ?1").bind(id),
|
||||
)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(bat_core::Error::NotFound(id.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count(&self, query: ResourceQuery) -> bat_core::Result<u64> {
|
||||
self.count_resources(&query).await
|
||||
}
|
||||
}
|
||||
|
||||
fn push_condition_prefix<'a>(builder: &mut QueryBuilder<'a, Sqlite>, has_where: &mut bool) {
|
||||
if *has_where {
|
||||
builder.push(" AND ");
|
||||
} else {
|
||||
builder.push(" WHERE ");
|
||||
*has_where = true;
|
||||
}
|
||||
}
|
||||
|
||||
type ResourceRow = (
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
String,
|
||||
);
|
||||
|
||||
fn glob_to_like(pattern: &str) -> String {
|
||||
let mut escaped = String::new();
|
||||
let mut chars = pattern.chars().peekable();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'*' => {
|
||||
if matches!(chars.peek(), Some('*')) {
|
||||
chars.next();
|
||||
}
|
||||
escaped.push('%');
|
||||
}
|
||||
'?' => escaped.push('_'),
|
||||
'%' | '_' | '\\' => {
|
||||
escaped.push('\\');
|
||||
escaped.push(ch);
|
||||
}
|
||||
other => escaped.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
escaped
|
||||
}
|
||||
|
||||
fn query_matches(query: &ResourceQuery, resource: &Resource) -> bool {
|
||||
if let Some(resource_type) = query.resource_type {
|
||||
if resource.entry.resource_type != resource_type {
|
||||
@@ -142,6 +499,8 @@ mod tests {
|
||||
hash: hash.to_string(),
|
||||
size: 7,
|
||||
resource_type,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -214,4 +573,56 @@ mod tests {
|
||||
);
|
||||
assert_eq!(repository.count(ResourceQuery::all()).await.unwrap(), 2);
|
||||
}
|
||||
|
||||
async fn sqlite_repository() -> (tempfile::TempDir, SqliteResourceRepository) {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let repository = SqliteResourceRepository::new(temp_dir.path().join("resources.sqlite"))
|
||||
.await
|
||||
.unwrap();
|
||||
(temp_dir, repository)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_repository_persists_and_filters_resources() {
|
||||
let (_temp_dir, repository) = sqlite_repository().await;
|
||||
let mut resource = resource(
|
||||
"resource/sqlite-a",
|
||||
"assets/model.bundle",
|
||||
"hash-sqlite-a",
|
||||
ResourceType::AssetBundle,
|
||||
);
|
||||
resource.entry.address = Some("Character_001".to_string());
|
||||
resource
|
||||
.entry
|
||||
.dependencies
|
||||
.push("assets/shared.bundle".to_string());
|
||||
|
||||
repository.add(resource.clone()).await.unwrap();
|
||||
|
||||
let by_id = repository.find_by_id(&resource.id).await.unwrap();
|
||||
assert_eq!(by_id.entry.address.as_deref(), Some("Character_001"));
|
||||
assert_eq!(
|
||||
by_id.entry.dependencies,
|
||||
vec!["assets/shared.bundle".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
repository.find_by_hash("hash-sqlite-a").await.unwrap().id,
|
||||
resource.id
|
||||
);
|
||||
|
||||
let bundles = repository
|
||||
.list(ResourceQuery::by_type(ResourceType::AssetBundle))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bundles.len(), 1);
|
||||
|
||||
let count = repository.count(ResourceQuery::all()).await.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
|
||||
repository.delete(&resource.id).await.unwrap();
|
||||
assert!(matches!(
|
||||
repository.find_by_id(&resource.id).await,
|
||||
Err(bat_core::Error::NotFound(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user