mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 00:55:15 +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:
@@ -9,6 +9,9 @@ license.workspace = true
|
||||
crate-type = ["cdylib", "staticlib"]
|
||||
|
||||
[dependencies]
|
||||
bat-core = { path = "../../core" }
|
||||
bat-adapters = { path = "../../adapters" }
|
||||
bat-infrastructure = { path = "../../infrastructure" }
|
||||
bat-cas-engine = { path = "../bat-cas-engine" }
|
||||
bat-assetbundle = { path = "../bat-assetbundle" }
|
||||
bat-patch = { path = "../bat-patch" }
|
||||
@@ -16,6 +19,7 @@ bat-patch = { path = "../bat-patch" }
|
||||
anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
# FFI 绑定
|
||||
libc = "0.2"
|
||||
|
||||
+383
-9
@@ -6,23 +6,122 @@
|
||||
|
||||
#![warn(clippy::all)]
|
||||
|
||||
use std::ffi::CString;
|
||||
use bat_adapters::manifest::{GenericManifest, ManifestDriverRegistry};
|
||||
use bat_adapters::official::yostar_jp::{
|
||||
PatchPlatform, YostarJpResourceEndpoint, YostarJpResourceEndpointChange,
|
||||
YostarJpResourceEndpointKind, YostarJpSyncSnapshot,
|
||||
};
|
||||
use bat_infrastructure::official_sync::{build_official_sync_plan, OfficialSyncDecision};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::raw::c_char;
|
||||
|
||||
/// FFI 版本号
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Sync snapshot JSON 输入。
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SyncSnapshotInput {
|
||||
connection_group_name: String,
|
||||
app_version: String,
|
||||
bundle_version: Option<String>,
|
||||
addressables_root: String,
|
||||
endpoints: Vec<SyncEndpointInput>,
|
||||
}
|
||||
|
||||
/// Sync endpoint JSON 输入。
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SyncEndpointInput {
|
||||
kind: String,
|
||||
platform: Option<String>,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct FfiResult<T> {
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
data: Option<T>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ManifestResourceView {
|
||||
path: String,
|
||||
hash: String,
|
||||
size: u64,
|
||||
resource_type: String,
|
||||
address: Option<String>,
|
||||
dependencies: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ManifestInspectView {
|
||||
format: String,
|
||||
locator_id: Option<String>,
|
||||
cdn_prefixes: Vec<String>,
|
||||
resource_count: usize,
|
||||
resources: Vec<ManifestResourceView>,
|
||||
metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SyncEndpointView {
|
||||
kind: String,
|
||||
platform: Option<String>,
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SyncEndpointChangeView {
|
||||
change_type: String,
|
||||
previous: Option<SyncEndpointView>,
|
||||
current: Option<SyncEndpointView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SyncSnapshotView {
|
||||
connection_group_name: String,
|
||||
app_version: String,
|
||||
bundle_version: Option<String>,
|
||||
addressables_root: String,
|
||||
endpoints: Vec<SyncEndpointView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SyncDeltaView {
|
||||
is_initial: bool,
|
||||
connection_group_changed: bool,
|
||||
app_version_changed: bool,
|
||||
bundle_version_changed: bool,
|
||||
addressables_root_changed: bool,
|
||||
root_token_changed: bool,
|
||||
endpoint_changes: Vec<SyncEndpointChangeView>,
|
||||
needs_download: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SyncPlanView {
|
||||
decision: String,
|
||||
should_download: bool,
|
||||
should_publish: bool,
|
||||
snapshot: SyncSnapshotView,
|
||||
delta: SyncDeltaView,
|
||||
}
|
||||
|
||||
/// 获取版本号(C ABI)
|
||||
#[no_mangle]
|
||||
pub extern "C" fn bat_version() -> *const c_char {
|
||||
CString::new(VERSION).unwrap().into_raw()
|
||||
c_string(VERSION).into_raw()
|
||||
}
|
||||
|
||||
/// 释放 C 字符串内存
|
||||
///
|
||||
/// # Safety
|
||||
/// 调用者必须确保:
|
||||
/// - `s` 是通过 `bat_version` 返回的有效指针
|
||||
/// - `s` 是通过 `bat_version` / `bat_manifest_inspect_json` / `bat_sync_plan_json` 返回的有效指针
|
||||
/// - `s` 只被释放一次
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn bat_free_string(s: *mut c_char) {
|
||||
@@ -31,20 +130,295 @@ pub unsafe extern "C" fn bat_free_string(s: *mut c_char) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 Addressables Manifest,并返回 JSON summary。
|
||||
#[no_mangle]
|
||||
pub extern "C" fn bat_manifest_inspect_json(raw_json: *const c_char) -> *mut c_char {
|
||||
let result = (|| -> Result<ManifestInspectView, String> {
|
||||
let raw_json = c_str_to_string(raw_json)?;
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let manifest: GenericManifest = runtime.block_on(async {
|
||||
ManifestDriverRegistry::with_defaults()
|
||||
.parse(raw_json.as_bytes())
|
||||
.await
|
||||
})?;
|
||||
|
||||
Ok(ManifestInspectView {
|
||||
format: format!("{:?}", manifest.format),
|
||||
locator_id: manifest.metadata.locator_id,
|
||||
cdn_prefixes: manifest.metadata.cdn_prefixes,
|
||||
resource_count: manifest.resources.len(),
|
||||
resources: manifest
|
||||
.resources
|
||||
.into_iter()
|
||||
.map(|resource| ManifestResourceView {
|
||||
path: resource.path,
|
||||
hash: resource.hash,
|
||||
size: resource.size,
|
||||
resource_type: format!("{:?}", resource.resource_type),
|
||||
address: resource.address,
|
||||
dependencies: resource.dependencies,
|
||||
})
|
||||
.collect(),
|
||||
metadata: manifest.metadata.extra,
|
||||
})
|
||||
})();
|
||||
|
||||
json_result(result)
|
||||
}
|
||||
|
||||
/// 构建官方同步计划 JSON summary。
|
||||
#[no_mangle]
|
||||
pub extern "C" fn bat_sync_plan_json(
|
||||
current_json: *const c_char,
|
||||
previous_json: *const c_char,
|
||||
) -> *mut c_char {
|
||||
let result = (|| -> Result<SyncPlanView, String> {
|
||||
let current = parse_sync_snapshot(current_json)?;
|
||||
let previous = if previous_json.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(parse_sync_snapshot(previous_json)?)
|
||||
};
|
||||
|
||||
let plan = build_official_sync_plan(current.clone(), previous.as_ref());
|
||||
let snapshot = snapshot_view(&plan.snapshot);
|
||||
let delta = delta_view(&plan.delta);
|
||||
|
||||
Ok(SyncPlanView {
|
||||
decision: sync_decision_to_string(plan.decision),
|
||||
should_download: plan.should_download(),
|
||||
should_publish: plan.should_publish(),
|
||||
snapshot,
|
||||
delta,
|
||||
})
|
||||
})();
|
||||
|
||||
json_result(result)
|
||||
}
|
||||
|
||||
fn parse_sync_snapshot(raw_json: *const c_char) -> Result<YostarJpSyncSnapshot, String> {
|
||||
let raw_json = c_str_to_string(raw_json)?;
|
||||
let input: SyncSnapshotInput = serde_json::from_str(&raw_json)
|
||||
.map_err(|error| format!("Invalid sync snapshot JSON: {error}"))?;
|
||||
|
||||
Ok(YostarJpSyncSnapshot {
|
||||
connection_group_name: input.connection_group_name,
|
||||
app_version: input.app_version,
|
||||
bundle_version: input.bundle_version,
|
||||
addressables_root: input.addressables_root,
|
||||
endpoints: input
|
||||
.endpoints
|
||||
.into_iter()
|
||||
.map(endpoint_from_input)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn endpoint_from_input(input: SyncEndpointInput) -> Result<YostarJpResourceEndpoint, String> {
|
||||
Ok(YostarJpResourceEndpoint {
|
||||
kind: parse_endpoint_kind(&input.kind)?,
|
||||
platform: input
|
||||
.platform
|
||||
.map(|value| parse_platform(&value))
|
||||
.transpose()?,
|
||||
url: input.url,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_endpoint_kind(value: &str) -> Result<YostarJpResourceEndpointKind, String> {
|
||||
match value {
|
||||
"TableCatalog" => Ok(YostarJpResourceEndpointKind::TableCatalog),
|
||||
"TableCatalogHash" => Ok(YostarJpResourceEndpointKind::TableCatalogHash),
|
||||
"AddressablesCatalog" => Ok(YostarJpResourceEndpointKind::AddressablesCatalog),
|
||||
"AddressablesCatalogHash" => Ok(YostarJpResourceEndpointKind::AddressablesCatalogHash),
|
||||
"BundlePackingInfo" => Ok(YostarJpResourceEndpointKind::BundlePackingInfo),
|
||||
"BundlePackingInfoHash" => Ok(YostarJpResourceEndpointKind::BundlePackingInfoHash),
|
||||
"MediaCatalog" => Ok(YostarJpResourceEndpointKind::MediaCatalog),
|
||||
"MediaCatalogHash" => Ok(YostarJpResourceEndpointKind::MediaCatalogHash),
|
||||
other => Err(format!("Unknown sync endpoint kind: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_platform(value: &str) -> Result<PatchPlatform, String> {
|
||||
match value {
|
||||
"Windows" => Ok(PatchPlatform::Windows),
|
||||
"Android" => Ok(PatchPlatform::Android),
|
||||
other => Err(format!("Unknown patch platform: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_view(snapshot: &YostarJpSyncSnapshot) -> SyncSnapshotView {
|
||||
SyncSnapshotView {
|
||||
connection_group_name: snapshot.connection_group_name.clone(),
|
||||
app_version: snapshot.app_version.clone(),
|
||||
bundle_version: snapshot.bundle_version.clone(),
|
||||
addressables_root: snapshot.addressables_root.clone(),
|
||||
endpoints: snapshot.endpoints.iter().map(endpoint_view).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_view(endpoint: &YostarJpResourceEndpoint) -> SyncEndpointView {
|
||||
SyncEndpointView {
|
||||
kind: format!("{:?}", endpoint.kind),
|
||||
platform: endpoint
|
||||
.platform
|
||||
.map(|platform| platform.as_str().to_string()),
|
||||
url: endpoint.url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn delta_view(delta: &bat_adapters::official::yostar_jp::YostarJpSyncDelta) -> SyncDeltaView {
|
||||
SyncDeltaView {
|
||||
is_initial: delta.is_initial,
|
||||
connection_group_changed: delta.connection_group_changed,
|
||||
app_version_changed: delta.app_version_changed,
|
||||
bundle_version_changed: delta.bundle_version_changed,
|
||||
addressables_root_changed: delta.addressables_root_changed,
|
||||
root_token_changed: delta.root_token_changed,
|
||||
endpoint_changes: delta
|
||||
.endpoint_changes
|
||||
.iter()
|
||||
.map(endpoint_change_view)
|
||||
.collect(),
|
||||
needs_download: delta.needs_download,
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint_change_view(change: &YostarJpResourceEndpointChange) -> SyncEndpointChangeView {
|
||||
match change {
|
||||
YostarJpResourceEndpointChange::Added(endpoint) => SyncEndpointChangeView {
|
||||
change_type: "Added".to_string(),
|
||||
previous: None,
|
||||
current: Some(endpoint_view(endpoint)),
|
||||
},
|
||||
YostarJpResourceEndpointChange::Removed(endpoint) => SyncEndpointChangeView {
|
||||
change_type: "Removed".to_string(),
|
||||
previous: Some(endpoint_view(endpoint)),
|
||||
current: None,
|
||||
},
|
||||
YostarJpResourceEndpointChange::Updated { previous, current } => SyncEndpointChangeView {
|
||||
change_type: "Updated".to_string(),
|
||||
previous: Some(endpoint_view(previous)),
|
||||
current: Some(endpoint_view(current)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_decision_to_string(decision: OfficialSyncDecision) -> String {
|
||||
match decision {
|
||||
OfficialSyncDecision::UpToDate => "UpToDate".to_string(),
|
||||
OfficialSyncDecision::DownloadAndVerify => "DownloadAndVerify".to_string(),
|
||||
OfficialSyncDecision::DownloadVerifyAndPublish => "DownloadVerifyAndPublish".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_result<T: Serialize>(result: Result<T, String>) -> *mut c_char {
|
||||
let payload = match result {
|
||||
Ok(data) => FfiResult {
|
||||
ok: true,
|
||||
error: None,
|
||||
data: Some(data),
|
||||
},
|
||||
Err(error) => FfiResult::<T> {
|
||||
ok: false,
|
||||
error: Some(error),
|
||||
data: None,
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&payload).unwrap_or_else(|error| {
|
||||
format!(
|
||||
r#"{{"ok":false,"error":"failed to serialize FFI response: {}","data":null}}"#,
|
||||
error
|
||||
)
|
||||
});
|
||||
c_string(json).into_raw()
|
||||
}
|
||||
|
||||
fn c_string(value: impl Into<String>) -> CString {
|
||||
CString::new(value.into()).unwrap_or_else(|_| CString::new("").unwrap())
|
||||
}
|
||||
|
||||
fn c_str_to_string(value: *const c_char) -> Result<String, String> {
|
||||
if value.is_null() {
|
||||
return Err("null pointer".to_string());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
CStr::from_ptr(value)
|
||||
.to_str()
|
||||
.map(|value| value.to_string())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::CStr;
|
||||
|
||||
fn take_string(ptr: *const c_char) -> String {
|
||||
assert!(!ptr.is_null());
|
||||
let value = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_string();
|
||||
unsafe { bat_free_string(ptr as *mut c_char) };
|
||||
value
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ffi_version() {
|
||||
let version_ptr = bat_version();
|
||||
assert!(!version_ptr.is_null());
|
||||
let version = take_string(version_ptr);
|
||||
assert!(!version.is_empty());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let version_cstr = CStr::from_ptr(version_ptr);
|
||||
let version = version_cstr.to_str().unwrap();
|
||||
assert!(!version.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn test_manifest_inspect_json() {
|
||||
let catalog_json = r#"{
|
||||
"m_LocatorId": "AddressablesMainContentCatalog",
|
||||
"m_InternalIds": [
|
||||
"synthetic/minimal.bundle"
|
||||
],
|
||||
"m_Entries": [
|
||||
{
|
||||
"internal_id": "synthetic/minimal.bundle",
|
||||
"hash": "abc",
|
||||
"size": 10,
|
||||
"address": "Character_001",
|
||||
"dependencies": ["shared.bundle"]
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let result_ptr = bat_manifest_inspect_json(CString::new(catalog_json).unwrap().as_ptr());
|
||||
let result = take_string(result_ptr);
|
||||
assert!(result.contains(r#""ok":true"#));
|
||||
assert!(result.contains(r#""resource_count":1"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_plan_json() {
|
||||
let current = r#"{
|
||||
"connection_group_name":"Prod-Audit",
|
||||
"app_version":"1.70.0",
|
||||
"bundle_version":"s8tloc7lo3",
|
||||
"addressables_root":"https://prod-clientpatch.bluearchiveyostar.com/r93_token",
|
||||
"endpoints":[
|
||||
{
|
||||
"kind":"TableCatalog",
|
||||
"platform":null,
|
||||
"url":"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let result_ptr =
|
||||
bat_sync_plan_json(CString::new(current).unwrap().as_ptr(), std::ptr::null());
|
||||
let result = take_string(result_ptr);
|
||||
assert!(result.contains(r#""ok":true"#));
|
||||
assert!(result.contains(r#""decision":"DownloadVerifyAndPublish""#));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user