feat: complete hosted mod sync and launcher interface updates

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?))
}

View File

@ -168,6 +168,7 @@ pub fn emit_loading(
.to_string(),
event: loading_bar.bar_type.clone(),
loader_uuid: loading_bar.loading_bar_uuid,
total: Some(loading_bar.total),
},
)
.map_err(EventError::from)?;
@ -181,6 +182,64 @@ pub fn emit_loading(
Ok(())
}
/// Set measured phase progress without completing the task at a phase boundary.
/// A zero total represents a phase whose amount of work is not yet known.
pub fn set_loading(
key: &LoadingBarId,
current: u64,
total: u64,
message: &str,
) -> crate::Result<()> {
let event_state = crate::EventState::get()?;
let Some(mut bar) = event_state.loading_bars.get_mut(&key.0) else {
return Err(EventError::NoLoadingBar(key.0).into());
};
bar.current = current.min(total) as f64;
bar.total = total as f64;
bar.message = message.to_owned();
let fraction = if total == 0 {
0.0
} else {
bar.current / bar.total
};
bar.last_sent = fraction;
#[cfg(feature = "tauri")]
event_state
.app
.emit(
"loading",
LoadingPayload {
fraction: Some(fraction),
message: bar.message.clone(),
event: bar.bar_type.clone(),
loader_uuid: bar.loading_bar_uuid,
total: Some(bar.total),
},
)
.map_err(EventError::from)?;
#[cfg(feature = "cli")]
{
bar.cli_progress_bar.set_message(bar.message.clone());
bar.cli_progress_bar
.set_position((fraction * CLI_PROGRESS_BAR_TOTAL as f64) as u64);
}
Ok(())
}
pub fn fail_hosted_loading(key: &LoadingBarId, error: &str) {
if let Ok(state) = crate::EventState::get()
&& let Some(mut bar) = state.loading_bars.get_mut(&key.0)
{
match &mut bar.bar_type {
LoadingBarType::HostedPackSync { error: failure, .. }
| LoadingBarType::HostedModDownload { error: failure, .. } => {
*failure = Some(error.to_owned())
}
_ => {}
}
}
}
// emit_warning(message)
pub async fn emit_warning(message: &str) -> crate::Result<()> {
#[cfg(feature = "tauri")]

View File

@ -109,6 +109,7 @@ impl Drop for LoadingBarId {
message: "Completed".to_string(),
event,
loader_uuid,
total: Some(bar.total),
},
);
tracing::trace!(
@ -137,6 +138,18 @@ impl Drop for LoadingBarId {
#[serde(rename_all = "snake_case")]
pub enum LoadingBarType {
LegacyDataMigration,
HostedPackSync {
instance_id: String,
instance_name: String,
error: Option<String>,
},
HostedModDownload {
instance_id: String,
instance_name: String,
batch_id: String,
file_name: String,
error: Option<String>,
},
DirectoryMove {
old: PathBuf,
new: PathBuf,
@ -189,6 +202,7 @@ pub struct LoadingPayload {
pub loader_uuid: Uuid,
pub fraction: Option<f64>, // by convention, if optional, it means the loading is done
pub message: String,
pub total: Option<f64>,
}
#[derive(Serialize, Clone)]

View File

@ -642,11 +642,17 @@ pub(crate) fn is_native_library(library: &Library) -> bool {
.is_some_and(|classifier| classifier.starts_with("natives-"))
}
/// Whether this library carries a Java artifact (regular JAR) that must be
/// downloaded and placed on the classpath. A library can have both a Java
/// Whether this library carries a non-native primary artifact that must be
/// downloaded. This is normally a Java JAR, but loader processors can also
/// declare archives and mapping files. A library can have both a primary
/// artifact and native classifiers after manifest merging (LWJGL is the
/// canonical example); the two are independent and must not be treated as
/// mutually exclusive.
///
/// `include_in_classpath` only controls the final Minecraft runtime classpath.
/// Forge and NeoForge deliberately exclude installer processor dependencies
/// from that classpath, but those artifacts are still required while the
/// loader processors run.
pub(crate) fn needs_java_artifact(library: &Library) -> bool {
// Four-part native coordinates (group:artifact:version:natives-*) store
// their native archive metadata in downloads.artifact, which is not a
@ -671,7 +677,7 @@ pub(crate) fn needs_java_artifact(library: &Library) -> bool {
{
return false;
}
library.include_in_classpath
true
}
fn java_artifact_applies(
@ -2853,6 +2859,26 @@ mod tests {
);
}
#[test]
fn neoforge_processor_dependency_is_downloaded_outside_runtime_classpath() {
let library: Library = serde_json::from_value(serde_json::json!({
"name": "net.neoforged.installertools:installertools:2.1.2",
"include_in_classpath": false,
"downloadable": true,
"downloads": {"artifact": {
"path": "net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar",
"url": "https://maven.neoforged.net/releases/net/neoforged/installertools/installertools/2.1.2/installertools-2.1.2.jar",
"sha1": "72524c0362f812d8aa4cdb4c03e9b45e2b71ae3b",
"size": 83543
}}
}))
.unwrap();
assert!(!library.include_in_classpath);
assert!(needs_java_artifact(&library));
assert!(java_artifact_applies(&library, "x86_64", false));
}
#[test]
fn java_artifact_rules_are_applied_before_path_deduplication() {
let blocked: Library = serde_json::from_value(serde_json::json!({

View File

@ -83,23 +83,109 @@ pub(crate) fn is_env_dependency_id(id: &str) -> bool {
pub fn extract_mod_metadata(bytes: &Bytes) -> Option<LocalModMetadata> {
let cursor = std::io::Cursor::new(&**bytes);
let mut archive = zip::ZipArchive::new(cursor).ok()?;
extract_archive_metadata(&mut archive)
}
/// Read only metadata entries without loading the entire Mod JAR into memory.
pub fn read_mod_metadata(path: &std::path::Path) -> Option<LocalModMetadata> {
let mut archive =
zip::ZipArchive::new(std::fs::File::open(path).ok()?).ok()?;
for name in [
"fabric.mod.json",
"quilt.mod.json",
"META-INF/mods.toml",
"META-INF/neoforge.mods.toml",
"mcmod.info",
"META-INF/MANIFEST.MF",
] {
if archive
.by_name(name)
.ok()
.is_some_and(|entry| entry.size() > 2 * 1024 * 1024)
{
return None;
}
}
extract_archive_metadata(&mut archive)
}
/// All top-level Mod IDs in a JAR, used to replace multi-Mod archives safely.
pub fn read_mod_ids(path: &std::path::Path) -> Vec<String> {
use std::io::Read;
let Ok(file) = std::fs::File::open(path) else {
return Vec::new();
};
let Ok(mut archive) = zip::ZipArchive::new(file) else {
return Vec::new();
};
let mut ids = std::collections::BTreeSet::new();
for name in ["META-INF/neoforge.mods.toml", "META-INF/mods.toml"] {
let Ok(file) = archive.by_name(name) else {
continue;
};
if file.size() > 2 * 1024 * 1024 {
continue;
}
let mut content = String::new();
if file
.take(2 * 1024 * 1024)
.read_to_string(&mut content)
.is_ok()
&& let Ok(parsed) = toml::from_str::<toml_mod::ModsToml>(&content)
{
ids.extend(
parsed
.mods
.unwrap_or_default()
.into_iter()
.filter_map(|entry| entry.mod_id),
);
}
}
for name in ["fabric.mod.json", "quilt.mod.json", "mcmod.info"] {
let Ok(file) = archive.by_name(name) else {
continue;
};
if file.size() > 2 * 1024 * 1024 {
continue;
}
if let Ok(value) = serde_json::from_reader::<_, serde_json::Value>(
file.take(2 * 1024 * 1024),
) {
if let Some(id) = value
.get("id")
.or_else(|| value.pointer("/quilt_loader/id"))
.and_then(|v| v.as_str())
{
ids.insert(id.to_owned());
}
if let Some(mods) = value.as_array() {
ids.extend(mods.iter().filter_map(|entry| {
entry.get("modid")?.as_str().map(str::to_owned)
}));
}
}
}
ids.into_iter().collect()
}
fn extract_archive_metadata<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Option<LocalModMetadata> {
// Try each known metadata path in priority order.
if let Some(meta) = try_fabric(&mut archive) {
if let Some(meta) = try_fabric(archive) {
return Some(meta);
}
if let Some(meta) = try_quilt(&mut archive) {
if let Some(meta) = try_quilt(archive) {
return Some(meta);
}
if let Some(meta) =
try_toml_path(&mut archive, "META-INF/neoforge.mods.toml")
{
if let Some(meta) = try_toml_path(archive, "META-INF/neoforge.mods.toml") {
return Some(meta);
}
if let Some(meta) = try_toml_path(&mut archive, "META-INF/mods.toml") {
if let Some(meta) = try_toml_path(archive, "META-INF/mods.toml") {
return Some(meta);
}
if let Some(meta) = try_mcmod_info(&mut archive) {
if let Some(meta) = try_mcmod_info(archive) {
return Some(meta);
}
@ -108,8 +194,8 @@ pub fn extract_mod_metadata(bytes: &Bytes) -> Option<LocalModMetadata> {
// ── format-specific parsers ────────────────────────────────────────────────
fn try_fabric(
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
fn try_fabric<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Option<LocalModMetadata> {
let mut file = archive.by_name("fabric.mod.json").ok()?;
let parsed: fabric::FabricModJson =
@ -141,8 +227,8 @@ fn try_fabric(
})
}
fn try_quilt(
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
fn try_quilt<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Option<LocalModMetadata> {
let mut file = archive.by_name("quilt.mod.json").ok()?;
let parsed: fabric::QuiltModJson =
@ -167,8 +253,8 @@ fn try_quilt(
})
}
fn try_toml_path(
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
fn try_toml_path<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
path: &str,
) -> Option<LocalModMetadata> {
let mut content = String::new();
@ -263,8 +349,8 @@ fn try_toml_path(
})
}
fn try_mcmod_info(
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
fn try_mcmod_info<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
) -> Option<LocalModMetadata> {
let mut file = archive.by_name("mcmod.info").ok()?;
let entries: Vec<mcmod_info::McmodInfoEntry> =
@ -324,9 +410,9 @@ fn extract_contact_url(contact: &Option<serde_json::Value>) -> Option<String> {
/// `${file.jarVersion}`) which the loader substitutes from the JAR manifest at
/// runtime; surface the real `Implementation-Version` from the manifest when
/// present, falling back to the original value otherwise.
fn resolve_toml_version(
fn resolve_toml_version<R: std::io::Read + std::io::Seek>(
version: Option<String>,
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
archive: &mut zip::ZipArchive<R>,
) -> Option<String> {
let placeholder = version.clone()?;
if !placeholder.starts_with("${") {
@ -345,6 +431,21 @@ fn resolve_toml_version(
mod tests {
use std::io::Write;
#[test]
fn reads_every_declared_mod_id_from_disk_without_including_dependencies() {
let jar = build_jar(&[(
"META-INF/mods.toml",
"[[mods]]\nmodId = \"first\"\nversion = \"1\"\n[[mods]]\nmodId = \"second\"\nversion = \"1\"\n[[dependencies.first]]\nmodId = \"minecraft\"\n",
)]);
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(&jar).unwrap();
assert_eq!(super::read_mod_ids(file.path()), vec!["first", "second"]);
assert_eq!(
super::read_mod_metadata(file.path()).unwrap().mod_id,
"first"
);
}
fn build_jar(entries: &[(&str, &str)]) -> bytes::Bytes {
let mut buffer = std::io::Cursor::new(Vec::new());
{

View File

@ -107,6 +107,7 @@ pub(crate) async fn try_download_via_h2(
destination: &Path,
part_path: &Path,
policy: super::native::NativeH2Policy,
progress: Option<&mut fetch::FetchProgressFn<'_>>,
) -> H2DownloadOutcome {
if request
.cancellation
@ -229,6 +230,7 @@ pub(crate) async fn try_download_via_h2(
part_path,
total_size,
concurrency,
progress,
)
.await;
}
@ -282,6 +284,7 @@ pub(crate) async fn try_download_via_h2(
&integrity,
total_size,
policy,
progress,
)
.await;
match result {
@ -460,6 +463,7 @@ async fn single_stream(
integrity: &Integrity,
total_size: u64,
policy: super::native::NativeH2Policy,
mut progress: Option<&mut fetch::FetchProgressFn<'_>>,
) -> crate::Result<DownloadResult> {
let mut headers = request_headers(request, route);
headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("identity"));
@ -511,6 +515,9 @@ async fn single_stream(
super::h2_receive::release_capacity(&mut stream, chunk.len())?;
if progress_gate.should_report(downloaded, total_size) {
record_install_progress(request, downloaded, total_size).await;
if let Some(callback) = progress.as_deref_mut() {
callback(downloaded, total_size).await?;
}
}
if policy.abort_if_slow
&& matches!(
@ -530,6 +537,9 @@ async fn single_stream(
}
file.flush().await?;
drop(file);
if let Some(callback) = progress.as_deref_mut() {
callback(downloaded, total_size).await?;
}
let computed = hashers.finish(downloaded);
record_install_stage(
request,
@ -759,6 +769,96 @@ impl AssetBatchConnectionGroup {
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
#[tokio::test]
async fn single_stream_reports_bytes_before_the_response_finishes() {
let size = 512 * 1024;
let data = Bytes::from(vec![42; size]);
let progress_seen = Arc::new(tokio::sync::Notify::new());
let server_progress = Arc::clone(&progress_seen);
let server_data = data.clone();
let (client_io, server_io) = tokio::io::duplex(1024 * 1024);
let server = tokio::spawn(async move {
let mut connection =
h2::server::handshake(server_io).await.unwrap();
while let Some(result) = connection.accept().await {
let (_, mut respond) = result.unwrap();
let data = server_data.clone();
let progress = Arc::clone(&server_progress);
tokio::spawn(async move {
let mut stream = respond
.send_response(http::Response::new(()), false)
.unwrap();
stream.send_data(data.slice(..size / 2), false).unwrap();
progress.notified().await;
stream.send_data(data.slice(size / 2..), true).unwrap();
});
}
});
let mut builder = h2::client::Builder::new();
builder.initial_window_size(1024 * 1024);
let (sender, driver) =
builder.handshake::<_, Bytes>(client_io).await.unwrap();
let client = tokio::spawn(driver);
let connection = SharedH2Connection::for_test(sender);
let directory = tempfile::tempdir().unwrap();
let destination = directory.path().join("pack.zip");
let part = directory.path().join("pack.zip.part");
let integrity =
Integrity::sha1(sha1_smol::Sha1::from(&data[..]).hexdigest())
.with_size(size as u64);
let request = DownloadRequest::new(
"https://h2-progress.test/pack.zip",
fetch::ResourceClass::Modpack,
)
.with_integrity(integrity.clone());
let route = DownloadRoute {
url: request.url.clone(),
source: fetch::DownloadRouteSource::Official,
is_mirror: false,
allow_sensitive_headers: true,
supports_range: true,
proxy: fetch::ProxyPolicy::Direct,
};
let mut reports = Vec::new();
let mut progress = |current, total| {
reports.push((current, total));
progress_seen.notify_one();
Box::pin(async { Ok(()) })
as Pin<Box<dyn Future<Output = crate::Result<()>> + Send>>
};
let result = tokio::time::timeout(
Duration::from_secs(5),
single_stream(
&connection,
&request.url.parse().unwrap(),
&request,
&route,
&destination,
&part,
&integrity,
size as u64,
super::super::native::NativeH2Policy {
allow_cold_connection: true,
abort_if_slow: false,
expected_speed: None,
},
Some(&mut progress),
),
)
.await;
client.abort();
server.abort();
result.unwrap().unwrap();
assert!(
reports
.iter()
.any(|&(current, total)| current > 0 && current < total)
);
assert_eq!(reports.last(), Some(&(size as u64, size as u64)));
assert_eq!(tokio::fs::read(destination).await.unwrap(), data);
}
#[test]
fn asset_batch_expansion_requires_sustained_saturation() {

View File

@ -54,6 +54,7 @@ pub(crate) async fn download(
part_path: &Path,
total_size: u64,
concurrency: usize,
mut progress: Option<&mut fetch::FetchProgressFn<'_>>,
) -> H2DownloadOutcome {
if request
.cancellation
@ -103,19 +104,33 @@ pub(crate) async fn download(
progress_delta,
));
}
let mut progress_interval =
tokio::time::interval(Duration::from_millis(200));
progress_interval
.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let cancellation = request.cancellation.clone().unwrap_or_default();
loop {
let next = if let Some(cancellation) = request.cancellation.as_ref() {
tokio::select! {
biased;
_ = cancellation.cancelled() => {
let next = tokio::select! {
biased;
_ = cancellation.cancelled() => {
drop(tasks);
drop(output);
return H2DownloadOutcome::Canceled;
}
_ = progress_interval.tick(), if progress.is_some() => {
if let Some(callback) = progress.as_deref_mut()
&& callback(downloaded.load(Ordering::Relaxed).min(total_size), total_size).await.is_err()
{
drop(tasks);
drop(output);
return H2DownloadOutcome::Canceled;
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Io,
preserve_partial: false,
};
}
result = tasks.next() => result,
continue;
}
} else {
tasks.next().await
result = tasks.next() => result,
};
let Some(result) = next else {
break;
@ -130,6 +145,14 @@ pub(crate) async fn download(
}
}
drop(output);
if let Some(callback) = progress.as_deref_mut()
&& callback(total_size, total_size).await.is_err()
{
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Io,
preserve_partial: false,
};
}
let verification = if let Some(cancellation) = request.cancellation.as_ref()
{
tokio::select! {
@ -385,6 +408,10 @@ mod tests {
)
.unwrap();
offset += length;
if offset == start + length {
tokio::time::sleep(Duration::from_millis(300))
.await;
}
}
});
}
@ -420,6 +447,18 @@ mod tests {
};
let uri = route.url.parse().unwrap();
let mut reports = Vec::new();
let mut progress = |current, total| {
reports.push((current, total));
Box::pin(async { Ok(()) })
as std::pin::Pin<
Box<
dyn std::future::Future<Output = crate::Result<()>>
+ Send,
>,
>
};
let result = download(
&connection,
&uri,
@ -429,10 +468,16 @@ mod tests {
&part_path,
data.len() as u64,
8,
Some(&mut progress),
)
.await;
assert!(matches!(result, H2DownloadOutcome::Completed(_)));
let size = data.len() as u64;
assert!(reports.iter().any(|&(current, total)| current > 0
&& current < size
&& total == size));
assert_eq!(reports.last(), Some(&(size, size)));
assert_eq!(request_count.load(Ordering::Relaxed), 8);
assert_eq!(tokio::fs::read(destination).await.unwrap(), *data);
client_driver.abort();
@ -504,6 +549,7 @@ mod tests {
&part_for_task,
2 * 1024 * 1024,
16,
None,
)
.await
});

View File

@ -5626,6 +5626,7 @@ async fn download_to_path_inner(
destination,
&part_path,
h2_policy,
progress.as_deref_mut(),
)
.await
{