feat: complete hosted mod sync and launcher interface updates
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled

Add pack sync markers, tagged mod updates, parallel progress, JWT downloads and retry recovery. Include pending onboarding, about scene, compatibility data pack and download fixes.
This commit is contained in:
2026-09-15 19:06:56 +08:00
parent 5d473ebfbc
commit bc904065c3
63 changed files with 3516 additions and 1059 deletions

View File

@ -34,6 +34,7 @@ pub async fn plan_instance_upgrade(
instance_id: &str,
target_environment: crate::state::InstanceUpgradeEnvironment,
) -> crate::Result<InstanceUpgradePlan> {
ensure_local_version_management(instance_id).await?;
let state = State::get().await?;
let creation_watch =
state.file_watcher.content_watch_snapshot(instance_id).await;
@ -380,6 +381,7 @@ pub async fn execute_instance_upgrade(
let state = State::get().await?;
let handle = stored_plan_handle(plan_id)?;
let mut stored = handle.lock().await;
ensure_local_version_management(&stored.plan.instance_id).await?;
if stored.execution_started {
return Err(crate::ErrorKind::InputError(
"Upgrade plan execution has already started".to_string(),
@ -638,10 +640,22 @@ fn stored_plan_handle(plan_id: &str) -> crate::Result<StoredUpgradePlan> {
})
}
async fn ensure_local_version_management(
instance_id: &str,
) -> crate::Result<()> {
if crate::pack::hosted::instance_mode(instance_id).await?
== crate::state::InstanceMode::StarLight
{
return Err(crate::ErrorKind::InputError("StarLight 实例的游戏版本和加载器由服务器整合包管理,不能手动选择版本".into()).as_error());
}
Ok(())
}
async fn ensure_current_revision(
stored: &mut StoredUpgradePlanState,
state: &State,
) -> crate::Result<crate::state::instances::commands::ReadOnlyUpgradeSource> {
ensure_local_version_management(&stored.plan.instance_id).await?;
let current_revision = content_rows::get_applied_content_set(
&stored.plan.instance_id,
&state.pool,

View File

@ -1,4 +1,6 @@
//! Administrator-approved skin-site packs. Only content-addressed changed files cross the network.
mod progress;
mod tagged;
use crate::{
State,
state::{
@ -6,9 +8,12 @@ use crate::{
InstanceLaunchOverridesPatch, InstanceMode, ModLoader,
},
util::fetch::{
DownloadRequest, Integrity, ResourceClass, download_to_path, fetch_json,
DownloadRequest, Integrity, ResourceClass, configured_client,
download_to_path,
},
};
use futures::FutureExt;
use progress::PackProgress;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
@ -26,6 +31,42 @@ const BINDING: &str = ".starlight-pack.json";
const JOURNAL: &str = ".starlight-pack-pending.json";
static GATES: LazyLock<dashmap::DashMap<String, Arc<Mutex<()>>>> =
LazyLock::new(dashmap::DashMap::new);
static SESSION: Mutex<Option<DownloadSession>> = Mutex::const_new(None);
struct DownloadSession {
authorization: String,
updated: std::time::Instant,
}
impl DownloadSession {
fn new(token: String) -> crate::Result<Self> {
if token.is_empty()
|| token.len() > 16_384
|| token.bytes().any(|b| !b.is_ascii_graphic())
{
return Err(invalid("StarLight 登录凭据无效,请重新登录"));
}
Ok(Self {
authorization: format!("Bearer {token}"),
updated: std::time::Instant::now(),
})
}
fn authorization(&self) -> crate::Result<String> {
if self.updated.elapsed() > std::time::Duration::from_secs(90) {
return Err(invalid("StarLight 登录状态需要刷新,请重试"));
}
Ok(self.authorization.clone())
}
}
/// The embedded skin site's JWT is held in memory only; the server validates it.
pub async fn set_session(token: Option<String>) -> crate::Result<()> {
let mut session = SESSION.lock().await;
*session = None;
*session = token.map(DownloadSession::new).transpose()?;
Ok(())
}
fn instance_gate(instance_id: &str) -> Arc<Mutex<()>> {
GATES.entry(instance_id.to_owned()).or_default().clone()
@ -38,9 +79,13 @@ pub struct Runtime {
pub loader: String,
pub loader_version: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PackFile {
#[serde(default)]
pub mod_ids: Vec<String>,
#[serde(default)]
pub external: Option<External>,
pub path: String,
pub sha256: String,
pub size: u64,
@ -49,7 +94,7 @@ pub struct PackFile {
#[serde(default)]
pub preserve: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct External {
pub project_id: u32,
@ -83,6 +128,25 @@ pub struct Publication {
pub struct Binding {
pub publication: Publication,
pub files: Vec<PackFile>,
#[serde(default)]
pub sync_marker: Option<String>,
#[serde(default)]
pub resolved_external: Vec<PackFile>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SyncState {
pack_id: String,
release_id: u64,
marker: String,
}
fn marker_matches(previous: &Binding, state: &SyncState) -> bool {
previous.sync_marker.as_deref() == Some(state.marker.as_str())
&& state.marker == format!("{}:{}", state.pack_id, state.release_id)
&& previous.publication.pack_id == state.pack_id
&& previous.publication.release_id == state.release_id
}
#[derive(Deserialize)]
struct Response<T> {
@ -228,25 +292,105 @@ fn write_json<T: Serialize>(
file.persist(dest).map_err(|e| e.error)?;
Ok(())
}
async fn authorization() -> crate::Result<String> {
SESSION
.lock()
.await
.as_ref()
.ok_or_else(|| {
invalid("请先登录 StarLight 皮肤站,再下载整合包;无需选择玩家")
})?
.authorization()
}
async fn ensure_session(auth: &str) -> crate::Result<()> {
if !SESSION
.lock()
.await
.as_ref()
.is_some_and(|session| session.authorization == auth)
{
return Err(invalid("StarLight 登录状态已变化,请重试整合包同步"));
}
Ok(())
}
async fn request<T: serde::de::DeserializeOwned>(
suffix: &str,
) -> crate::Result<T> {
let auth = authorization().await?;
request_authorized(suffix, &auth).await
}
async fn request_authorized<T: serde::de::DeserializeOwned>(
suffix: &str,
auth: &str,
) -> crate::Result<T> {
ensure_session(auth).await?;
let response = configured_client()
.await?
.get(format!("{API}{suffix}"))
.header(reqwest::header::AUTHORIZATION, auth)
.header(reqwest::header::CACHE_CONTROL, "no-cache")
.timeout(std::time::Duration::from_secs(30))
.send()
.await?;
if matches!(response.status().as_u16(), 401 | 403) {
return Err(invalid(
"StarLight 登录凭据已失效或无权限,请重新登录后重试",
));
}
let data: Response<T> = response.error_for_status()?.json().await?;
ensure_session(auth).await?;
Ok(data.payload)
}
pub async fn default_publication() -> crate::Result<Publication> {
request::<Option<Publication>>("/default")
.await?
.ok_or_else(|| {
invalid("管理员尚未指定已发布的默认整合包,请联系服务器管理员")
})
}
pub async fn create() -> crate::Result<String> {
let publication = default_publication().await?;
let runtime = &publication.manifest.runtime;
let state = State::get().await?;
let response: Response<T> = fetch_json(
reqwest::Method::GET,
&format!("{API}{suffix}"),
None,
None,
None,
&state.api_semaphore,
&state.pool,
let instance = crate::state::create_instance(
crate::state::CreateInstance {
name: publication.manifest.name.clone(),
path: None,
game_version: runtime.game_version.clone(),
loader: ModLoader::try_from_string(&runtime.loader)?,
loader_version: runtime.loader_version.clone(),
icon_path: None,
link: crate::state::InstanceLink::Unmanaged,
symlink_target: None,
game_dir_override: None,
},
&state,
)
.await?;
Ok(response.payload)
}
pub async fn catalog() -> crate::Result<Vec<Publication>> {
request("").await
crate::instance::edit(
&instance.id,
EditInstance {
launch_overrides: Some(InstanceLaunchOverridesPatch {
instance_mode: Some(InstanceMode::StarLight),
..Default::default()
}),
..Default::default()
},
)
.await?;
crate::event::emit::emit_instance(
&instance.id,
crate::event::InstancePayloadType::Created,
)
.await?;
Ok(instance.id)
}
pub async fn binding(instance_id: &str) -> crate::Result<Option<Binding>> {
read_json(&crate::instance::get_full_path(instance_id).await?, BINDING)
.await
@ -394,17 +538,14 @@ async fn recover(instance_id: &str) -> crate::Result<()> {
Ok(())
}
pub async fn synchronize(
instance_id: &str,
pack_id: &str,
) -> crate::Result<SyncResult> {
pub async fn synchronize(instance_id: &str) -> crate::Result<SyncResult> {
let _guard = instance_gate(instance_id).lock_owned().await;
if instance_mode(instance_id).await? != InstanceMode::StarLight {
return Err(invalid(
"请先将实例类型设为 StarLight 实例,再同步官方整合包",
));
}
synchronize_locked(instance_id, pack_id).await
synchronize_locked(instance_id).await
}
fn effective_mode(
@ -451,6 +592,9 @@ pub async fn set_instance_mode(
"外部关联、共享目录或由第三方整合包平台管理的实例不能开启 StarLight 自动同步,请创建独立实例",
));
}
if mode == InstanceMode::StarLight {
synchronize_locked(instance_id).await?;
}
crate::instance::edit(
instance_id,
EditInstance {
@ -472,25 +616,37 @@ pub async fn prepare_launch(
let guard = instance_gate(instance_id).lock_owned().await;
recover(instance_id).await?;
if instance_mode(instance_id).await? == InstanceMode::StarLight {
let b = binding(instance_id).await?.ok_or_else(|| invalid("StarLight 实例尚未配置官方整合包,请在 Mod 管理 → 管理整合包中选择并安装后再启动"))?;
if !offline {
synchronize_locked(instance_id, &b.publication.pack_id).await?;
if offline {
return Err(invalid(
"StarLight 实例必须登录并联网检查更新后才能启动;离线游玩请使用本地实例",
));
}
synchronize_locked(instance_id).await?;
}
Ok(guard)
}
async fn synchronize_locked(
instance_id: &str,
pack_id: &str,
) -> crate::Result<SyncResult> {
uuid::Uuid::parse_str(pack_id)
.map_err(|_| invalid("Invalid modpack ID"))?;
async fn synchronize_locked(instance_id: &str) -> crate::Result<SyncResult> {
ensure_idle(instance_id).await?;
recover(instance_id).await?;
let metadata = crate::instance::get(instance_id)
.await?
.ok_or_else(|| invalid("Unknown instance"))?;
let progress =
PackProgress::new(instance_id, &metadata.instance.name).await?;
let result =
synchronize_with_progress(instance_id, metadata, &progress).await;
if let Err(error) = &result {
progress.fail(error);
}
result
}
async fn synchronize_with_progress(
instance_id: &str,
metadata: crate::state::InstanceMetadata,
progress: &PackProgress,
) -> crate::Result<SyncResult> {
if metadata.instance.linked_launcher.is_some()
|| metadata.instance.symlink_target.is_some()
|| !matches!(
@ -505,29 +661,45 @@ async fn synchronize_locked(
}
let root = crate::instance::get_full_path(instance_id).await?;
let previous: Option<Binding> = read_json(&root, BINDING).await?;
if previous
let auth = authorization().await?;
let sync_state =
request_authorized::<Option<SyncState>>("/sync-state", &auth)
.await?
.ok_or_else(|| {
invalid("管理员尚未指定已发布的默认整合包,请联系服务器管理员")
})?;
let pack_unchanged = previous
.as_ref()
.is_some_and(|b| b.publication.pack_id != pack_id)
{
return Err(invalid(
"This instance is bound to another modpack; create a separate instance",
));
}
let publication: Publication = request(&format!("/{pack_id}")).await?;
if publication.pack_id != pack_id
|| publication.manifest.schema_version != 1
{
.is_some_and(|binding| marker_matches(binding, &sync_state))
&& metadata.instance.install_stage == InstanceInstallStage::Installed;
let publication = if pack_unchanged {
progress.update(0, 0, "整合包已是最新,正在检查标签 Mod", true);
previous.as_ref().unwrap().publication.clone()
} else {
request_authorized::<Option<Publication>>("/default", &auth)
.await?
.ok_or_else(|| {
invalid("管理员尚未指定已发布的默认整合包,请联系服务器管理员")
})?
};
let sync_marker =
format!("{}:{}", publication.pack_id, publication.release_id);
uuid::Uuid::parse_str(&publication.pack_id)
.map_err(|_| invalid("Invalid modpack ID"))?;
if publication.manifest.schema_version != 1 {
return Err(invalid("Invalid modpack publication"));
}
if previous
.as_ref()
.is_some_and(|b| b.publication.release_id > publication.release_id)
{
if previous.as_ref().is_some_and(|b| {
b.publication.pack_id == publication.pack_id
&& b.publication.release_id > publication.release_id
}) {
return Err(invalid("Server returned an older modpack publication"));
}
let manifest = &publication.manifest;
let loader = ModLoader::try_from_string(&manifest.runtime.loader)?;
let resolved_loader = if loader == ModLoader::Vanilla {
let resolved_loader = if pack_unchanged {
metadata.applied_content_set.loader_version.clone()
} else if loader == ModLoader::Vanilla {
None
} else {
Some(crate::launcher::get_loader_version_from_profile(&manifest.runtime.game_version, loader, manifest.runtime.loader_version.as_deref()).await?.ok_or_else(|| invalid("Modpack loader version is unavailable for this Minecraft version"))?.id)
@ -541,6 +713,18 @@ async fn synchronize_locked(
tokio::fs::create_dir_all(&cache).await?;
let state = State::get().await?;
let mut files = manifest.files.clone();
let tagged_manifest = request_authorized::<tagged::TaggedManifest>(
&format!("/tagged-mods/{}", publication.release_id),
&auth,
)
.await?;
tagged_manifest.validate()?;
let mut resolved_external = Vec::new();
if pack_unchanged {
resolved_external =
previous.as_ref().unwrap().resolved_external.clone();
files.extend(resolved_external.iter().cloned());
}
validate(&files)?;
let mut downloaded = 0;
let mut sources = BTreeMap::new();
@ -551,7 +735,20 @@ async fn synchronize_locked(
);
}
// Resolve CurseForge references using the launcher's existing API and integrity checks.
for external in &manifest.external {
for (external_index, external) in manifest.external.iter().enumerate() {
if pack_unchanged {
break;
}
progress.update(
0,
0,
&format!(
"正在解析外部模组 {}/{}",
external_index + 1,
manifest.external.len()
),
true,
);
let cf = crate::api::curseforge::get_file(
external.project_id,
external.file_id,
@ -598,6 +795,19 @@ async fn synchronize_locked(
invalid("This CurseForge file requires a manual download")
})?,
};
let label = format!(
"外部模组 {}/{} · {}",
external_index + 1,
manifest.external.len(),
cf.file_name
);
let mut transferred = 0;
let mut on_progress = |received: u64, _total: u64| {
transferred = received.min(cf.file_length);
progress.download(transferred, cf.file_length, &label, false);
futures::future::ready(Ok(())).boxed()
};
progress.download(0, cf.file_length, &label, true);
download_to_path(
DownloadRequest::new(url, ResourceClass::CurseForge)
.with_integrity(
@ -606,9 +816,10 @@ async fn synchronize_locked(
&staged,
&state.fetch_semaphore,
&state.pool,
None,
Some(&mut on_progress),
)
.await?;
progress.download(cf.file_length, cf.file_length, &label, true);
downloaded += cf.file_length;
} else if crate::util::fetch::sha1_file_async(&staged).await?
!= (cf.file_length, sha1)
@ -622,14 +833,26 @@ async fn synchronize_locked(
if !object.exists() {
tokio::fs::copy(&staged, object).await?;
}
files.push(PackFile {
let resolved = PackFile {
mod_ids: tagged::mod_ids(target(&cache, &sha256)?).await?,
external: Some(external.clone()),
path,
sha256,
size: cf.file_length,
force: true,
preserve: false,
});
};
resolved_external.push(resolved.clone());
files.push(resolved);
}
let duplicate_mods = tagged::merge(
&root,
&cache,
&mut files,
&mut sources,
&tagged_manifest,
)
.await?;
validate(&files)?;
if let Some(old) = &previous {
validate(&old.files)?;
@ -639,6 +862,7 @@ async fn synchronize_locked(
.map(|b| b.files.iter().map(|f| (f.path.as_str(), f)).collect())
.unwrap_or_default();
let mut actions = Vec::new();
let mut pending_downloads = BTreeMap::new();
let mut preserved = Vec::new();
let next_paths: BTreeMap<_, _> = files
.iter()
@ -654,7 +878,21 @@ async fn synchronize_locked(
));
}
}
for f in &files {
for (index, f) in files.iter().enumerate() {
if pack_unchanged
&& !tagged_manifest.contains(&f.path)
&& old
.get(f.path.as_str())
.is_some_and(|prior| prior.sha256 == f.sha256)
{
continue;
}
progress.update(
index as u64,
files.len() as u64,
&format!("正在检查文件 · {}", f.path),
false,
);
let live = target(&root, &f.path)?;
let local = hash(&live).await?;
if local.as_deref() == Some(&f.sha256) {
@ -681,20 +919,9 @@ async fn synchronize_locked(
let url = sources
.get(&f.path)
.ok_or_else(|| invalid("Missing modpack download source"))?;
download_to_path(
DownloadRequest::new(url, ResourceClass::Modpack)
.with_integrity(Integrity {
size: Some(f.size),
sha256: Some(f.sha256.clone()),
..Default::default()
}),
&object,
&state.fetch_semaphore,
&state.pool,
None,
)
.await?;
downloaded += f.size;
pending_downloads
.entry(f.sha256.clone())
.or_insert_with(|| (f.clone(), url.clone(), object));
}
actions.push(Action {
path: f.path.clone(),
@ -708,7 +935,9 @@ async fn synchronize_locked(
if local.is_none() {
continue;
}
if prior.preserve || local.as_deref() != Some(&prior.sha256) {
if !tagged::is_managed_file(prior)
&& (prior.preserve || local.as_deref() != Some(&prior.sha256))
{
preserved.push(prior.path.clone());
continue;
}
@ -719,12 +948,110 @@ async fn synchronize_locked(
});
}
}
for action in duplicate_mods {
preserved.retain(|path| path != &action.path);
if !actions.iter().any(|existing| existing.path == action.path) {
actions.push(action);
}
}
let mut tagged_downloads = Vec::new();
pending_downloads.retain(|_, (file, url, object)| {
if tagged_manifest.contains(&file.path) {
tagged_downloads.push((file.clone(), url.clone(), object.clone()));
false
} else {
true
}
});
let total_bytes = pending_downloads
.values()
.map(|(file, _, _)| file.size)
.sum::<u64>();
let total_files = pending_downloads.len();
let mut completed_bytes = 0;
for (index, (file, url, object)) in
pending_downloads.into_values().enumerate()
{
ensure_session(&auth).await?;
let url = if let Some(external) = &file.external {
match crate::api::curseforge::get_file(
external.project_id,
external.file_id,
)
.await?
.download_url
{
Some(url) => url,
None => crate::api::curseforge::get_download_url(
external.project_id,
external.file_id,
)
.await?
.ok_or_else(|| {
invalid("This CurseForge file requires a manual download")
})?,
}
} else {
url
};
let label =
format!("文件 {}/{} · {}", index + 1, total_files, file.path);
progress.download(completed_bytes, total_bytes, &label, index == 0);
let mut transferred = 0;
let mut on_progress = |received: u64, _total: u64| {
transferred = received.min(file.size);
progress.download(
completed_bytes + transferred,
total_bytes,
&label,
false,
);
futures::future::ready(Ok(())).boxed()
};
let mut request = DownloadRequest::new(&url, ResourceClass::Modpack)
.with_integrity(Integrity {
size: Some(file.size),
sha256: Some(file.sha256),
..Default::default()
});
if url.starts_with(&format!("{API}/files/")) {
request = request.with_header("Authorization", auth.clone());
}
download_to_path(
request,
&object,
&state.fetch_semaphore,
&state.pool,
Some(&mut on_progress),
)
.await?;
completed_bytes += file.size;
downloaded += file.size;
progress.download(
completed_bytes,
total_bytes,
&label,
index + 1 == total_files,
);
}
ensure_session(&auth).await?;
downloaded += tagged::download(
instance_id,
&metadata.instance.name,
tagged_downloads,
&auth,
progress,
)
.await?;
ensure_session(&auth).await?;
if actions.is_empty()
&& !runtime_changed
&& metadata.instance.install_stage == InstanceInstallStage::Installed
&& previous
.as_ref()
.is_some_and(|b| b.publication.release_id == publication.release_id)
&& previous.as_ref().is_some_and(|b| {
b.sync_marker.as_deref() == Some(sync_marker.as_str())
&& b.files == files
&& b.resolved_external == resolved_external
})
{
return Ok(SyncResult {
instance_id: instance_id.into(),
@ -742,6 +1069,7 @@ async fn synchronize_locked(
};
write_json(&root, JOURNAL, &journal)?;
let result = async {
progress.update(0, 0, "正在安装游戏组件", true);
// Stage all content before changing the game/runtime. The journal blocks launch until recovery.
crate::instance::edit(instance_id, EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
@ -751,22 +1079,46 @@ async fn synchronize_locked(
if runtime_changed || journal.metadata.instance.install_stage != InstanceInstallStage::Installed {
let mut job = crate::install::install_existing_instance(instance_id.to_string(), false).await?;
loop {
progress.install(&job);
use crate::install::InstallJobStatus::*;
match job.status {
Succeeded => break,
Queued | Running => { tokio::time::sleep(std::time::Duration::from_millis(250)).await; job = crate::install::get_job(job.job_id).await?; }
_ => { let _ = crate::install::cancel_job(job.job_id).await; return Err(invalid("Minecraft component installation did not finish; inspect Downloads and retry")); }
status => {
let details = job
.error
.as_ref()
.or(job.rollback_error.as_ref())
.map(|error| error.message.as_str())
.unwrap_or(match status {
WaitingForUser => "安装任务正在等待用户处理",
Interrupted => "安装任务意外中断",
Canceled | Canceling => "安装任务已取消",
Failed => "安装任务失败",
_ => "安装任务未能完成",
});
if status == WaitingForUser {
let _ = crate::install::cancel_job(job.job_id).await;
}
return Err(invalid(format!(
"Minecraft 游戏组件安装失败:{details};请在“下载”中查看详情后重试"
)));
}
}
}
}
ensure_session(&auth).await?;
progress.update(0, 0, "正在应用更新", true);
apply_files(&root, &cache, &journal.backup, &journal.actions).await?;
write_json(&root, BINDING, &Binding { publication: publication.clone(), files })?;
progress.update(0, 0, "正在完成安装", true);
write_json(&root, BINDING, &Binding { publication: publication.clone(), files, sync_marker: Some(sync_marker), resolved_external })?;
crate::instance::edit(instance_id, EditInstance { install_stage: Some(InstanceInstallStage::Installed), ..Default::default() }).await?;
crate::instance::sync_content_files(instance_id).await?;
tokio::fs::remove_file(target(&root, JOURNAL)?).await?;
Ok::<_, crate::Error>(())
}.await;
if let Err(error) = result {
progress.update(0, 0, "正在恢复安装前的文件", true);
ensure_idle(instance_id).await.map_err(|busy| invalid(format!("Sync failed: {error}; recovery is pending until the active installation stops: {busy}")))?;
restore(&root, &journal).await.map_err(|recovery| invalid(format!("Sync failed: {error}; recovery failed: {recovery}. Retry before launching.")))?;
return Err(error);
@ -783,6 +1135,58 @@ async fn synchronize_locked(
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sync_marker_requires_an_installed_publication_and_changes_on_default_or_release()
{
let mut binding: Binding = serde_json::from_value(serde_json::json!({
"publication": {"packId":"pack", "releaseId":3, "manifest": {
"schemaVersion":1, "name":"Pack", "version":"1", "format":"multimc",
"runtime":{"gameVersion":"1.20.1", "loader":"vanilla", "loaderVersion":null}, "files":[]
}}, "files":[]
})).unwrap();
let mut state = SyncState {
pack_id: "pack".into(),
release_id: 3,
marker: "pack:3".into(),
};
assert!(!marker_matches(&binding, &state));
binding.sync_marker = Some("pack:3".into());
assert!(marker_matches(&binding, &state));
state.release_id = 4;
assert!(!marker_matches(&binding, &state));
state.release_id = 3;
state.pack_id = "other".into();
assert!(!marker_matches(&binding, &state));
state.pack_id = "pack".into();
state.marker = "pack:4".into();
assert!(!marker_matches(&binding, &state));
}
#[test]
fn download_session_uses_site_token_without_a_game_profile() {
let mut session =
DownloadSession::new("site.jwt.token".into()).unwrap();
assert_eq!(session.authorization().unwrap(), "Bearer site.jwt.token");
session.updated -= std::time::Duration::from_secs(91);
assert!(session.authorization().is_err());
assert!(DownloadSession::new(String::new()).is_err());
assert!(
DownloadSession::new("token\r\nInjected: header".into()).is_err()
);
}
#[tokio::test]
async fn logout_and_invalid_replacement_clear_download_authorization() {
set_session(Some("first.jwt.token".into())).await.unwrap();
assert_eq!(authorization().await.unwrap(), "Bearer first.jwt.token");
let old = authorization().await.unwrap();
set_session(None).await.unwrap();
assert!(authorization().await.is_err());
assert!(ensure_session(&old).await.is_err());
set_session(Some("second.jwt.token".into())).await.unwrap();
assert!(ensure_session(&old).await.is_err());
assert!(set_session(Some("bad token".into())).await.is_err());
assert!(authorization().await.is_err());
}
#[tokio::test]
async fn instance_operations_exclude_each_other_without_blocking_other_instances()
{
@ -872,6 +1276,8 @@ mod tests {
#[test]
fn rejects_case_and_ancestor_collisions() {
let make = |p: &str| PackFile {
mod_ids: Vec::new(),
external: None,
path: p.into(),
sha256: "a".repeat(64),
size: 1,

View File

@ -0,0 +1,129 @@
use crate::event::{
LoadingBarId, LoadingBarType,
emit::{fail_hosted_loading, init_loading, set_loading},
};
use std::{
sync::Mutex,
time::{Duration, Instant},
};
pub(super) struct PackProgress {
bar: LoadingBarId,
last_update: Mutex<Instant>,
}
#[cfg(all(test, not(feature = "tauri")))]
mod tests {
use super::*;
#[tokio::test]
async fn phases_and_failure_remain_in_snapshots_until_the_task_ends() {
let state = crate::EventState::init().await.unwrap();
let progress = PackProgress::new("progress-test", "Test pack")
.await
.unwrap();
let snapshot = || {
state.loading_bars.iter().find(|bar| matches!(&bar.bar_type,
LoadingBarType::HostedPackSync { instance_id, .. } if instance_id == "progress-test"
)).map(|bar| bar.value().clone()).unwrap()
};
assert_eq!(snapshot().total, 0.0);
progress.download(524_288, 1_048_576, "mods/example.jar", true);
assert_eq!(snapshot().current / snapshot().total, 0.5);
assert!(snapshot().message.contains("0.5 / 1.0 MiB"));
progress.download(1_048_576, 1_048_576, "mods/example.jar", true);
assert_eq!(snapshot().current, snapshot().total);
progress.download(262_144, 1_048_576, "mods/example.jar", true);
assert_eq!(snapshot().current / snapshot().total, 0.25);
progress.update(0, 0, "正在应用更新", true);
assert_eq!(snapshot().message, "正在应用更新");
assert_eq!(snapshot().total, 0.0);
progress.fail(
&crate::ErrorKind::InputError("download failed".into()).as_error(),
);
assert!(matches!(
snapshot().bar_type,
LoadingBarType::HostedPackSync { error: Some(_), .. }
));
let id = snapshot().loading_bar_uuid;
drop(progress);
tokio::task::yield_now().await;
assert!(!state.loading_bars.contains_key(&id));
}
}
impl PackProgress {
pub async fn new(instance_id: &str, name: &str) -> crate::Result<Self> {
let bar = init_loading(
LoadingBarType::HostedPackSync {
instance_id: instance_id.to_owned(),
instance_name: name.to_owned(),
error: None,
},
1.0,
"正在获取整合包信息",
)
.await?;
let progress = Self {
bar,
last_update: Mutex::new(Instant::now()),
};
progress.update(0, 0, "正在获取整合包信息", true);
Ok(progress)
}
pub fn update(&self, current: u64, total: u64, message: &str, force: bool) {
let mut last = self.last_update.lock().unwrap();
if !force && last.elapsed() < Duration::from_millis(200) {
return;
}
*last = Instant::now();
let _ = set_loading(&self.bar, current, total, message);
}
pub fn download(&self, current: u64, total: u64, file: &str, force: bool) {
self.update(
current,
total,
&format!(
"正在下载 · {:.1} / {:.1} MiB · {file}",
current as f64 / 1_048_576.0,
total as f64 / 1_048_576.0
),
force,
);
}
pub fn install(&self, job: &crate::install::InstallJobSnapshot) {
use crate::install::InstallPhaseId::*;
let message = match job.phase {
PreparingJava => "正在准备 Java",
DownloadingMinecraft => "正在下载游戏组件",
ResolvingLoader | RunningLoaderProcessors | UpdatingLoader => {
"正在安装加载器"
}
Verifying => "正在校验游戏组件",
Finalizing | Completed => "正在完成游戏组件安装",
_ => "正在安装游戏组件",
};
let (current, total) = job
.progress
.as_ref()
.map(|p| (p.current, p.total))
.unwrap_or((0, 0));
let message = if let Some(bytes) = job.summary.bytes_total {
format!(
"{message} · {:.1} / {:.1} MiB",
job.summary.bytes_downloaded as f64 / 1_048_576.0,
bytes as f64 / 1_048_576.0
)
} else {
message.to_owned()
};
self.update(current, total, &message, false);
}
pub fn fail(&self, error: &crate::Error) {
fail_hosted_loading(&self.bar, &error.user_facing_message());
}
}

View File

@ -0,0 +1,428 @@
use super::*;
use crate::event::{
LoadingBarType,
emit::{fail_hosted_loading, init_loading, set_loading},
};
use futures::{StreamExt, stream};
use std::time::{Duration, Instant};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct TaggedFile {
mod_id: String,
mod_ids: Vec<String>,
path: String,
sha256: String,
size: u64,
}
#[derive(Deserialize)]
pub(super) struct TaggedManifest {
files: Vec<TaggedFile>,
replaces: Vec<String>,
}
impl TaggedManifest {
pub fn contains(&self, path: &str) -> bool {
self.files.iter().any(|file| file.path == path)
}
pub fn validate(&self) -> crate::Result<()> {
if self.files.len() > 8192 {
return Err(invalid("标签 Mod 数量过多"));
}
let mut ids = HashSet::new();
for file in &self.files {
if file.mod_id.is_empty()
|| file.mod_id.len() > 128
|| !file
.mod_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b))
|| file.path != format!("mods/{}.starlight.jar", file.mod_id)
|| !ids.insert(file.mod_id.to_ascii_lowercase())
{
return Err(invalid("标签 Mod 清单包含无效或重复标识"));
}
if !file.mod_ids.contains(&file.mod_id) || file.mod_ids.is_empty() {
return Err(invalid("标签 Mod 缺少完整的 Mod 标识"));
}
}
let all_ids: Vec<_> =
self.files.iter().flat_map(|file| &file.mod_ids).collect();
if all_ids.iter().any(|id| {
id.is_empty()
|| id.len() > 128
|| !id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b))
}) {
return Err(invalid("标签 Mod 包含无效标识"));
}
if all_ids.iter().collect::<HashSet<_>>().len() != all_ids.len() {
return Err(invalid("标签 Mod 存在重复标识"));
}
super::validate(
&self
.files
.iter()
.map(TaggedFile::pack_file)
.collect::<Vec<_>>(),
)?;
for path in &self.replaces {
safe_path(path)?;
if !is_mod_path(path) {
return Err(invalid("标签 Mod 替换路径无效"));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn manifest() -> TaggedManifest {
TaggedManifest {
files: vec![TaggedFile {
mod_id: "example".into(),
mod_ids: vec!["example".into()],
path: "mods/example.starlight.jar".into(),
sha256: "a".repeat(64),
size: 10,
}],
replaces: vec![],
}
}
#[tokio::test]
async fn replaces_external_mod_using_saved_identity_when_cache_is_missing()
{
let root = tempfile::tempdir().unwrap();
let cache = root.path().join("cache");
let original = PackFile {
mod_ids: vec!["example".into()],
external: Some(External {
project_id: 1,
file_id: 2,
}),
path: "mods/old.jar".into(),
sha256: "b".repeat(64),
size: 12,
force: true,
preserve: false,
};
let mut files = vec![original.clone()];
let mut sources = BTreeMap::new();
let manifest = manifest();
manifest.validate().unwrap();
let actions =
merge(root.path(), &cache, &mut files, &mut sources, &manifest)
.await
.unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "mods/example.starlight.jar");
assert!(actions.is_empty());
assert!(sources[&files[0].path].contains("/tagged-files/"));
let persisted = serde_json::to_vec(&original).unwrap();
let mut restored =
vec![serde_json::from_slice::<PackFile>(&persisted).unwrap()];
merge(
root.path(),
&cache,
&mut restored,
&mut sources,
&TaggedManifest {
files: vec![],
replaces: vec![],
},
)
.await
.unwrap();
assert_eq!(restored[0].external.as_ref().unwrap().file_id, 2);
assert_eq!(restored[0].path, "mods/old.jar");
}
#[tokio::test]
async fn local_duplicate_is_only_planned_for_removal_and_unrelated_mod_is_kept()
{
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join("mods")).unwrap();
for (name, id) in [("old.jar", "example"), ("personal.jar", "personal")]
{
let mut zip = zip::ZipWriter::new(
std::fs::File::create(root.path().join("mods").join(name))
.unwrap(),
);
zip.start_file(
"fabric.mod.json",
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(
format!(r#"{{"schemaVersion":1,"id":"{id}","version":"1"}}"#)
.as_bytes(),
)
.unwrap();
zip.finish().unwrap();
}
let actions = merge(
root.path(),
&root.path().join("cache"),
&mut vec![],
&mut BTreeMap::new(),
&manifest(),
)
.await
.unwrap();
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].path, "mods/old.jar");
assert!(actions[0].next_hash.is_none());
assert!(root.path().join("mods/old.jar").is_file());
assert!(root.path().join("mods/personal.jar").is_file());
}
#[test]
fn multi_mod_jar_cannot_be_partially_replaced() {
assert!(is_managed_file(&manifest().files[0].pack_file()));
let mut custom = manifest().files[0].pack_file();
custom.path = "mods/personal.jar".into();
assert!(!is_managed_file(&custom));
let declared = vec!["example".into(), "companion".into()];
assert!(replaces_ids(&declared, &HashSet::from(["example"])).is_err());
assert!(
replaces_ids(&declared, &HashSet::from(["example", "companion"]))
.unwrap()
);
assert!(!replaces_ids(&declared, &HashSet::from(["other"])).unwrap());
let mut bad = manifest();
bad.files[0].mod_ids.push("../escape".into());
assert!(bad.validate().is_err());
}
}
impl TaggedFile {
fn pack_file(&self) -> PackFile {
PackFile {
mod_ids: self.mod_ids.clone(),
external: None,
path: self.path.clone(),
sha256: self.sha256.clone(),
size: self.size,
force: true,
preserve: false,
}
}
}
fn is_mod_path(path: &str) -> bool {
path.starts_with("mods/")
&& path.to_ascii_lowercase().ends_with(".jar")
&& path.split('/').count() == 2
}
pub(super) fn is_managed_file(file: &PackFile) -> bool {
file.external.is_none()
&& file
.mod_ids
.iter()
.any(|id| file.path == format!("mods/{id}.starlight.jar"))
}
fn replaces_ids(
declared: &[String],
selected: &HashSet<&str>,
) -> crate::Result<bool> {
let overlaps = declared.iter().any(|id| selected.contains(id.as_str()));
if overlaps && !declared.iter().all(|id| selected.contains(id.as_str())) {
return Err(invalid(
"旧 JAR 同时包含多个 Mod标签未覆盖全部标识无法安全替换。请管理员更新完整 JAR 或整合包。",
));
}
Ok(overlaps)
}
pub(super) async fn mod_ids(path: PathBuf) -> crate::Result<Vec<String>> {
Ok(tokio::task::spawn_blocking(move || {
crate::mod_metadata::read_mod_ids(&path)
})
.await?)
}
pub(super) async fn merge(
root: &Path,
cache: &Path,
files: &mut Vec<PackFile>,
sources: &mut BTreeMap<String, String>,
manifest: &TaggedManifest,
) -> crate::Result<Vec<Action>> {
let ids: HashSet<_> = manifest
.files
.iter()
.flat_map(|file| file.mod_ids.iter().map(String::as_str))
.collect();
if ids.is_empty() {
return Ok(Vec::new());
}
let mut replaced: HashSet<String> =
manifest.replaces.iter().cloned().collect();
for file in files.iter().filter(|file| is_mod_path(&file.path)) {
if replaced.contains(&file.path) {
continue;
}
let object = target(cache, &file.sha256)?;
let declared = if !file.mod_ids.is_empty() {
file.mod_ids.clone()
} else {
mod_ids(object).await?
};
if replaces_ids(&declared, &ids)? {
replaced.insert(file.path.clone());
}
}
files.retain(|file| !replaced.contains(&file.path));
for file in &manifest.files {
if files
.iter()
.any(|existing| existing.path.eq_ignore_ascii_case(&file.path))
{
return Err(invalid(format!(
"标签 Mod 与整合包文件冲突:{}",
file.path
)));
}
sources.insert(
file.path.clone(),
format!("{API}/tagged-files/{}", file.sha256),
);
files.push(file.pack_file());
}
let mut duplicate_actions = Vec::new();
let mods_dir = target(root, "mods")?;
if mods_dir.is_dir() {
let mut entries = tokio::fs::read_dir(&mods_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let Some(name) = entry.file_name().to_str().map(str::to_owned)
else {
continue;
};
let path = format!("mods/{name}");
if !is_mod_path(&path) || manifest.contains(&path) {
continue;
}
let local = target(root, &path)?;
if replaces_ids(&mod_ids(local.clone()).await?, &ids)? {
duplicate_actions.push(Action {
path,
old_hash: hash(&local).await?,
next_hash: None,
});
}
}
}
Ok(duplicate_actions)
}
pub(super) async fn download(
instance_id: &str,
instance_name: &str,
files: Vec<(PackFile, String, PathBuf)>,
auth: &str,
progress: &PackProgress,
) -> crate::Result<u64> {
if files.is_empty() {
return Ok(0);
}
let state = State::get().await?;
let concurrency = crate::api::settings::get()
.await?
.effective_max_concurrent_downloads()
.clamp(1, 16);
let batch_id = uuid::Uuid::new_v4().to_string();
let total = files.iter().map(|(file, _, _)| file.size).sum::<u64>();
let current = std::sync::Mutex::new(BTreeMap::<String, u64>::new());
let mut work = Vec::new();
for (file, url, object) in files {
let bar = init_loading(
LoadingBarType::HostedModDownload {
instance_id: instance_id.into(),
instance_name: instance_name.into(),
batch_id: batch_id.clone(),
file_name: file.path.clone(),
error: None,
},
file.size as f64,
"等待下载",
)
.await?;
set_loading(&bar, 0, file.size, "等待下载")?;
work.push((file, url, object, bar));
}
let results =
stream::iter(work.into_iter().map(|(file, url, object, bar)| {
let current = &current;
let state = &state;
async move {
let result = async {
ensure_session(auth).await?;
set_loading(&bar, 0, file.size, "正在下载")?;
let mut last = Instant::now() - Duration::from_secs(1);
let mut report = |received: u64, _: u64| {
if last.elapsed() >= Duration::from_millis(200)
|| received >= file.size
{
last = Instant::now();
let _ = set_loading(
&bar,
received,
file.size,
"正在下载",
);
let mut bytes = current.lock().unwrap();
bytes.insert(
file.path.clone(),
received.min(file.size),
);
progress.download(
bytes.values().sum(),
total,
"标签 Mod",
false,
);
}
futures::future::ready(Ok(())).boxed()
};
download_to_path(
DownloadRequest::new(url, ResourceClass::Modpack)
.with_header("Authorization", auth.to_owned())
.with_integrity(Integrity {
size: Some(file.size),
sha256: Some(file.sha256),
..Default::default()
}),
&object,
&state.fetch_semaphore,
&state.pool,
Some(&mut report),
)
.await?;
ensure_session(auth).await?;
set_loading(&bar, file.size, file.size, "下载完成")?;
Ok::<_, crate::Error>(file.size)
}
.await;
if let Err(error) = &result {
fail_hosted_loading(&bar, &error.user_facing_message());
}
result
}
}))
.buffer_unordered(concurrency)
.collect::<Vec<_>>()
.await;
results
.into_iter()
.try_fold(0, |sum, result| Ok(sum + result?))
}