Initial Commit

This commit is contained in:
2026-06-20 23:03:06 +08:00
commit 51e16359de
25 changed files with 5676 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
use crate::CONFIG;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug)]
pub struct Config {
pub cwd: String,
pub name: String,
pub as_path: String, // AssetStudio
}
impl Config {
pub fn cache_dir(&self) -> PathBuf {
PathBuf::from(&self.cwd).join("cache").join(&self.name)
}
pub fn data_dir(&self) -> PathBuf {
PathBuf::from(&self.cwd).join("data").join(&self.name)
}
}
#[derive(Debug, Default)]
pub struct OptConfig {
pub proxy: Option<String>,
pub ghp: Option<String>,
}
impl OptConfig {
pub const fn new() -> Self {
Self {
proxy: None,
ghp: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct State {
pub git_hash: Option<String>,
pub master_hash: Option<String>,
pub event_id: Option<u64>,
}
impl State {
fn load(x: &str) -> Self {
serde_json::from_str(x).unwrap()
}
pub const fn new() -> Self {
Self {
git_hash: None,
master_hash: None,
event_id: None,
}
}
pub fn init(&mut self) -> anyhow::Result<()> {
let path = CONFIG.get().unwrap().data_dir().join("state.json");
if path.exists() {
let i = Self::load(&std::fs::read_to_string(path)?);
self.git_hash = i.git_hash;
self.master_hash = i.master_hash;
self.event_id = i.event_id;
}
Ok(())
}
pub fn save(&self) {
let d = CONFIG.get().unwrap().data_dir();
std::fs::create_dir_all(&d).unwrap();
let json = serde_json::to_string(self).unwrap();
std::fs::write(d.join("state.json"), json).unwrap();
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventStory {
pub id: u64,
#[serde(rename = "eventId")]
pub event_id: u64,
// outline: String,
#[serde(rename = "assetbundleName")]
pub assetbundle_name: String,
}
// 用这个找是因为这个文件小一点(((
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardEpisode {
pub id: u64,
pub seq: u64,
#[serde(rename = "cardId")]
pub card_id: u64,
#[serde(rename = "assetbundleName")]
pub assetbundle_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventCard {
pub id: u64,
#[serde(rename = "cardId")]
pub card_id: u64,
#[serde(rename = "eventId")]
pub event_id: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrentVersion {
#[serde(rename = "appHash")]
pub app_hash: String,
#[serde(rename = "systemProfile")]
pub system_profile: String,
#[serde(rename = "appVersion")]
pub app_version: String,
#[serde(rename = "assetVersion")]
pub asset_version: String,
#[serde(rename = "dataVersion")]
pub data_version: String,
#[serde(rename = "assetHash")]
pub asset_hash: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct FetchResult{
pub event_id: u64,
pub card_paths: Vec<PathBuf>,
}
+60
View File
@@ -0,0 +1,60 @@
use crate::{CONFIG, OPT_CONFIG};
use crate::github::get_master_data;
use assets_updater::core::asset_execution::AssetExecutionContext;
use assets_updater::core::config::{AppConfig, CryptoConfig, ExecutionConfig, RegionConfig, RegionProviderConfig, RegionRuntimeConfig, ToolsConfig};
use assets_updater::core::errors::AssetExecutionError;
use common::updater::SyncContext;
use std::path::PathBuf;
pub struct Downloader {
exec: AssetExecutionContext,
app_config: AppConfig,
}
impl Downloader {
pub async fn new() -> anyhow::Result<Downloader> {
let master_data = get_master_data().await?;
let mut execution = ExecutionConfig::default();
// if let Ok(opt_conf) = &OPT_CONFIG.read()
// && let Some(proxy) = &opt_conf.proxy
// {
// execution.proxy = Some(proxy.to_string());
// }
let app_config = AppConfig {
execution,
tools: ToolsConfig{
asset_studio_cli_path : Some(CONFIG.get().unwrap().as_path.clone()),
..Default::default()
},
..Default::default()
};
let mut exec = AssetExecutionContext::new(&app_config, &SyncContext{
region: "jp".to_string(),
export: Default::default(),
asset_version: Some(master_data.asset_version),
asset_hash: Some(master_data.asset_hash),
app_version: Some(master_data.app_version),
}, &RegionConfig {
provider: RegionProviderConfig::ColorfulPalette {
asset_info_url_template: "https://{env}-{hash}-assetbundle-info.sekai.colorfulpalette.org/api/version/{asset_version}/{asset_hash}/os/ios".to_owned(),
asset_bundle_url_template: "https://{env}-{hash}-assetbundle.sekai.colorfulpalette.org/{asset_version}/{asset_hash}/ios/{bundle_path}".to_owned(),
profile_hash: "cf2d2388".to_owned(),
},
crypto: CryptoConfig{
aes_iv_hex: Some("6732666343305a637a4e394d544a3631".to_owned()),
aes_key_hex: Some("6d737833495630693958453575595a31".to_owned()),
},
runtime: RegionRuntimeConfig{
unity_version: "2022.3.21f1".to_owned()
},
})?;
exec.fetch_runtime_cookies().await?;
Ok(Downloader { exec, app_config })
}
pub async fn pull(
&self,
bundle_path: &str,
) -> Result<(PathBuf, /*single file*/ bool), AssetExecutionError> {
self.exec.download(bundle_path, &self.app_config).await
}
}
+84
View File
@@ -0,0 +1,84 @@
use crate::data::CurrentVersion;
use crate::{OPT_CONFIG, STORE, UA};
use anyhow::anyhow;
use reqwest::Client;
use serde::Deserialize;
const COMMIT_URL: &str = "https://api.github.com/repos/Team-Haruki/haruki-sekai-master/commits?path=master/events.json&per_page=1";
#[derive(Debug, Deserialize)]
struct CommitResponse {
sha: String,
}
fn http_client() -> anyhow::Result<Client> {
let mut builder = Client::builder();
if let Ok(opt_conf) = &OPT_CONFIG.read() {
if let Some(proxy) = &opt_conf.proxy {
builder = builder.proxy(reqwest::Proxy::all(proxy)?);
}
if let Some(ghp) = &opt_conf.ghp {
builder = builder.default_headers({
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
format!("Bearer {ghp}").parse()?,
);
headers.insert(reqwest::header::USER_AGENT, UA.parse()?);
headers
});
}
}
Ok(builder.build()?)
}
pub async fn fetch_last_hash() -> anyhow::Result<String> {
let resp = http_client()?.get(COMMIT_URL).send().await?;
let text = resp.text().await?;
let data: Vec<CommitResponse> = serde_json::from_str(&text)?;
Ok(data.first().ok_or(anyhow!(""))?.sha.clone())
}
pub fn raw_url(path: &str) -> String {
"https://raw.githubusercontent.com/Team-Haruki/haruki-sekai-master/refs/heads/main/".to_owned()
+ path
}
pub async fn sync_data() -> anyhow::Result<()> {
let client = http_client()?;
let events_cards = &client
.get(raw_url("master/eventCards.json"))
.send()
.await?
.text()
.await?;
let event_stories = &client
.get(raw_url("master/eventStories.json"))
.send()
.await?
.text()
.await?;
let card_episodes = &client
.get(raw_url("master/cardEpisodes.json"))
.send()
.await?
.text()
.await?;
let mut store = STORE.write().map_err(|e| anyhow!("{}", e))?;
store.set_event_cards(serde_json::from_str(events_cards)?);
store.set_event_stories(serde_json::from_str(event_stories)?);
store.set_card_episodes(serde_json::from_str(card_episodes)?);
Ok(())
}
pub async fn get_master_data() -> anyhow::Result<CurrentVersion> {
let client = http_client()?;
let text = client
.get(raw_url("versions/current_version.json"))
.send()
.await?
.text()
.await?;
Ok(serde_json::from_str(&text)?)
}
+125
View File
@@ -0,0 +1,125 @@
mod data;
mod download;
mod github;
mod main_loop;
mod store;
use crate::data::{Config, FetchResult, OptConfig, State};
use crate::main_loop::main;
use crate::store::Store;
use log::{LevelFilter, debug};
use simplelog::{ColorChoice, TermLogger, TerminalMode};
use std::ffi::{CStr, CString, c_char};
use std::sync::{OnceLock, RwLock};
use std::thread;
use std::time::Duration;
use tokio::runtime::Runtime;
pub static CONFIG: OnceLock<Config> = OnceLock::new();
pub static OPT_CONFIG: RwLock<OptConfig> = RwLock::new(OptConfig::new());
pub static PROVIDE: RwLock<Option<FetchResult>> = RwLock::new(None);
pub static STORE: RwLock<Store> = RwLock::new(Store::new());
pub static STATE: RwLock<State> = RwLock::new(State::new());
pub const UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
unsafe fn parse_str(x: *const c_char) -> String {
if x.is_null() {
return "".to_owned();
}
let c_str = unsafe { CStr::from_ptr(x) };
c_str.to_string_lossy().into_owned()
}
macro_rules! def_opt_str_setter {
($name: ident) => {
/// # Safety
#[unsafe(no_mangle)]
pub unsafe extern "C" fn $name(x: *const c_char) {
let x = unsafe { parse_str(x) };
if x.is_empty() {
OPT_CONFIG.write().unwrap().$name = None;
} else {
OPT_CONFIG.write().unwrap().$name = Some(x);
}
}
};
}
def_opt_str_setter!(proxy);
def_opt_str_setter!(ghp);
/// # Safety
#[unsafe(no_mangle)]
pub unsafe extern "C" fn boot(
cwd: *const c_char,
name: *const c_char,
as_path: *const c_char,
) -> i32 {
unsafe {
CONFIG
.set(Config {
cwd: parse_str(cwd),
name: parse_str(name),
as_path: parse_str(as_path),
})
.unwrap();
}
STATE
.write()
.unwrap()
.init()
.expect("Failed to initialize state");
*STORE.write().unwrap() = Store::load();
// TermLogger::init(
// LevelFilter::Debug,
// simplelog::Config::default(),
// TerminalMode::Mixed,
// ColorChoice::Auto,
// )
// .unwrap();
thread::spawn(|| {
let rt = Runtime::new().expect("Failed to create Tokio runtime");
rt.block_on(keep_loop());
});
0
}
/// # Safety
#[unsafe(no_mangle)]
pub unsafe extern "C" fn fetch_data() -> *mut c_char {
let mut provide = PROVIDE.write().unwrap();
if provide.is_none() {
return std::ptr::null_mut();
}
let result = provide.take().unwrap();
*provide = None;
let json_string = serde_json::to_string(&result).unwrap();
let c_str = CString::new(json_string).unwrap();
c_str.into_raw()
}
/// # Safety
#[unsafe(no_mangle)]
pub unsafe extern "C" fn free_string(s: *mut c_char) {
if s.is_null() {
return;
}
unsafe {
let _ = CString::from_raw(s);
}
}
async fn keep_loop() {
loop {
debug!("loop_start");
if let Err(err) = main().await {
println!("Error: {}", err);
}
tokio::time::sleep(Duration::from_secs(60)).await;
}
}
+91
View File
@@ -0,0 +1,91 @@
use crate::data::{FetchResult, State};
use crate::download::Downloader;
use crate::github::{fetch_last_hash, sync_data};
use crate::{CONFIG, PROVIDE, STATE, STORE};
use anyhow::anyhow;
use assets_updater::core::export_pipeline::find_files;
use std::fs;
use log::debug;
pub async fn main() -> anyhow::Result<()> {
let current_hash = fetch_last_hash().await?;
let old_state = {
let mut l = STATE.read();
if l.is_err() {
STATE.clear_poison();
drop(l);
let mut s = STATE.write().map_err(|e| anyhow!("{}", e))?;
*s = State::new();
let _ = s.init();
drop(s);
l = STATE.read();
}
if let Ok(state) = l {
state.clone()
} else {
State::new()
}
};
if let Some(old_hash) = &old_state.git_hash
&& old_hash == &current_hash
{
return Ok(());
}
// hash updated
sync_data().await?;
let store = STORE.read().map_err(|e| anyhow!("{}", e))?;
let event = store.find_last_event();
if event
.as_ref()
.zip(old_state.event_id.as_ref())
.is_none_or(|(e, id)| &e.event_id == id)
{
let mut state = STATE.write().map_err(|e| anyhow!("{}", e))?;
state.git_hash = Some(current_hash);
if let Some(event) = event {
state.event_id = Some(event.event_id);
}
state.save();
return Ok(());
}
let event = event.unwrap();
// event updated
let cards = store.find_cards_by_event(event.event_id);
drop(store);
let downloader = Downloader::new().await?;
let mut images = Vec::new();
let cache_dir = CONFIG.get().unwrap().cache_dir();
for card in cards {
debug!("Pulling card {} from bundle {}", card.card_id, card.assetbundle_name);
let dir = downloader
.pull(&format!("character/member/{}", card.assetbundle_name))
.await?;
let files = find_files(&dir.0)?;
for file in files {
if file.is_file() && file.extension().map(|ext| ext == "png").unwrap_or(false) {
let new_filename = format!(
"{}_{}",
card.assetbundle_name,
file.file_name().unwrap().to_str().unwrap()
);
let new_file = cache_dir.join(new_filename);
fs::copy(&file, &new_file)?;
fs::remove_file(&file)?;
images.push(new_file);
}
}
}
*PROVIDE.write().map_err(|e| anyhow!("{}", e))? = Some(FetchResult {
event_id: event.event_id,
card_paths: images,
});
let mut state = STATE.write().map_err(|e| anyhow!("{}", e))?;
state.git_hash = Some(current_hash);
state.event_id = Some(event.event_id);
state.save();
Ok(())
}
+97
View File
@@ -0,0 +1,97 @@
use crate::CONFIG;
use crate::data::{CardEpisode, EventCard, EventStory};
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Default)]
pub struct Store {
pub event_stories: Vec<EventStory>,
pub card_episodes: Vec<CardEpisode>,
pub event_cards: Vec<EventCard>,
}
impl Store {
pub const fn new() -> Self {
Self {
event_cards: Vec::new(),
card_episodes: Vec::new(),
event_stories: Vec::new(),
}
}
pub fn load() -> Self {
let cache_dir = CONFIG.get().unwrap().cache_dir();
// 确保缓存目录存在
if !cache_dir.exists() {
let _ = fs::create_dir_all(&cache_dir);
}
Self {
event_stories: Self::load_file(&cache_dir.join("event_stories.json")),
card_episodes: Self::load_file(&cache_dir.join("card_episodes.json")),
event_cards: Self::load_file(&cache_dir.join("event_cards.json")),
}
}
fn load_file<T: DeserializeOwned>(path: &PathBuf) -> Vec<T> {
fs::read_to_string(path)
.ok()
.and_then(|content| serde_json::from_str(&content).ok())
.unwrap_or_default()
}
fn save_file<T: Serialize>(path: &PathBuf, data: &Vec<T>) {
if let Ok(content) = serde_json::to_string_pretty(data) {
let _ = fs::write(path, content);
}
}
pub fn set_event_stories(&mut self, data: Vec<EventStory>) {
self.event_stories = data;
let path = CONFIG.get().unwrap().cache_dir().join("event_stories.json");
Self::save_file(&path, &self.event_stories);
}
pub fn set_card_episodes(&mut self, data: Vec<CardEpisode>) {
self.card_episodes = data;
let path = CONFIG.get().unwrap().cache_dir().join("card_episodes.json");
Self::save_file(&path, &self.card_episodes);
}
pub fn set_event_cards(&mut self, data: Vec<EventCard>) {
self.event_cards = data;
let path = CONFIG.get().unwrap().cache_dir().join("event_cards.json");
Self::save_file(&path, &self.event_cards);
}
pub fn save_all(&self) {
let cache_dir = CONFIG.get().unwrap().cache_dir();
Self::save_file(&cache_dir.join("event_stories.json"), &self.event_stories);
Self::save_file(&cache_dir.join("card_episodes.json"), &self.card_episodes);
Self::save_file(&cache_dir.join("event_cards.json"), &self.event_cards);
}
pub fn find_last_event(&self) -> Option<EventStory> {
self.event_stories
.iter()
.max_by_key(|s| s.event_id)
.cloned()
}
pub fn find_cards_by_event(&self, event_id: u64) -> Vec<CardEpisode> {
self.event_cards
.iter()
.filter(|e| e.event_id == event_id)
.filter_map(|c| {
self.card_episodes
.iter()
.rev()
.find(|e| e.card_id == c.card_id)
})
.cloned()
.collect()
}
}