feat: 添加获批整合包同步与 StarLight 实例模式

This commit is contained in:
2026-09-14 22:59:16 +08:00
parent fbcaed528b
commit 2a1dfa3c42
29 changed files with 1891 additions and 225 deletions

View File

@ -75,6 +75,8 @@ async fn run_with_extra_launch_args_inner(
extra_launch_args: Option<Vec<String>>,
gc_intent: Option<GcLaunchIntent>,
) -> crate::Result<(ProcessMetadata, Option<GcLaunchReport>)> {
let _hosted_guard =
crate::pack::hosted::prepare_launch(instance_id, offline_mode).await?;
let state = State::get().await?;
let launch_preparation_timeout =
crate::state::instances::commands::get_instance_launch_context(

View File

@ -51,7 +51,7 @@ pub mod data {
InstanceContentSnapshot, InstanceContentSnapshotItem,
InstanceContentWarning, InstanceInstallCandidate,
InstanceInstallTarget, InstanceLaunchOverridesPatch, InstanceLink,
InstanceMetadata, InstancePostUpgradeNotice,
InstanceMetadata, InstanceMode, InstancePostUpgradeNotice,
InstancePostUpgradeWarning, InstanceUpgradeAction,
InstanceUpgradeDependencyChange, InstanceUpgradeDependencyChangeKind,
InstanceUpgradeEnvironment, InstanceUpgradeFixedConstraint,

View File

@ -0,0 +1,884 @@
//! Administrator-approved skin-site packs. Only content-addressed changed files cross the network.
use crate::{
State,
state::{
AppliedContentSetPatch, EditInstance, InstanceInstallStage,
InstanceLaunchOverridesPatch, InstanceMode, ModLoader,
},
util::fetch::{
DownloadRequest, Integrity, ResourceClass, download_to_path, fetch_json,
},
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, HashSet},
path::{Path, PathBuf},
sync::{Arc, LazyLock},
};
use tokio::{
io::AsyncReadExt,
sync::{Mutex, OwnedMutexGuard},
};
const API: &str = "https://skin.starlight.cool/starlight/mod/packs";
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);
fn instance_gate(instance_id: &str) -> Arc<Mutex<()>> {
GATES.entry(instance_id.to_owned()).or_default().clone()
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Runtime {
pub game_version: String,
pub loader: String,
pub loader_version: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackFile {
pub path: String,
pub sha256: String,
pub size: u64,
#[serde(default)]
pub force: bool,
#[serde(default)]
pub preserve: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct External {
pub project_id: u32,
pub file_id: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Manifest {
pub schema_version: u32,
pub name: String,
pub version: String,
pub format: String,
pub runtime: Runtime,
pub files: Vec<PackFile>,
#[serde(default)]
pub external: Vec<External>,
#[serde(default)]
pub java_arguments: Vec<String>,
#[serde(default)]
pub game_arguments: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Publication {
pub pack_id: String,
pub release_id: u64,
pub manifest: Manifest,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Binding {
pub publication: Publication,
pub files: Vec<PackFile>,
}
#[derive(Deserialize)]
struct Response<T> {
payload: T,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncResult {
pub instance_id: String,
pub version: String,
pub downloaded_bytes: u64,
pub changed_files: usize,
pub preserved_files: Vec<String>,
}
fn invalid(message: impl Into<String>) -> crate::Error {
crate::ErrorKind::InputError(message.into()).into()
}
fn safe_path(path: &str) -> crate::Result<()> {
if path.is_empty()
|| path.len() > 1024
|| path.contains('\\')
|| path.to_ascii_lowercase().starts_with(".starlight-")
|| path.split('/').any(|part| {
let stem =
part.split('.').next().unwrap_or("").to_ascii_lowercase();
part.is_empty()
|| part == "."
|| part == ".."
|| part.ends_with(['.', ' '])
|| part
.chars()
.any(|c| c.is_control() || ":*?\"<>|".contains(c))
|| matches!(stem.as_str(), "con" | "prn" | "aux" | "nul")
|| (stem.len() == 4
&& (stem.starts_with("com") || stem.starts_with("lpt"))
&& matches!(stem.as_bytes()[3], b'1'..=b'9'))
})
{
return Err(invalid(format!("Unsafe modpack path: {path}")));
}
Ok(())
}
fn validate(files: &[PackFile]) -> crate::Result<()> {
if files.iter().map(|f| u128::from(f.size)).sum::<u128>()
> 16 * 1024 * 1024 * 1024
|| files.len() > 100_000
{
return Err(invalid("Modpack has too many files"));
}
let mut paths = HashSet::new();
for f in files {
safe_path(&f.path)?;
if f.sha256.len() != 64
|| !f
.sha256
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
|| f.size > 2 * 1024 * 1024 * 1024
|| !paths.insert(f.path.to_lowercase())
{
return Err(invalid(
"Invalid hash, size or duplicate modpack path",
));
}
}
for f in files {
let mut p = Path::new(&f.path).parent();
while let Some(parent) = p {
if paths.contains(
&parent.to_string_lossy().replace('\\', "/").to_lowercase(),
) {
return Err(invalid("Modpack file/directory conflict"));
}
p = parent.parent();
}
}
Ok(())
}
/// Reject symlinks and junctions in every existing ancestor, including internal state files.
fn target(root: &Path, relative: &str) -> crate::Result<PathBuf> {
let mut path = root.to_path_buf();
for part in relative.split('/') {
path.push(part);
match std::fs::symlink_metadata(&path) {
Ok(meta) => {
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
if meta.file_attributes() & 0x400 != 0 {
return Err(invalid(
"Modpack path is a junction or symlink",
));
}
}
if meta.file_type().is_symlink() {
return Err(invalid("Modpack path is a symlink"));
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
Err(e) => return Err(e.into()),
}
}
Ok(path)
}
async fn hash(path: &Path) -> crate::Result<Option<String>> {
let mut input = match tokio::fs::File::open(path).await {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
let mut digest = Sha256::new();
let mut buffer = vec![0; 64 * 1024];
loop {
let n = input.read(&mut buffer).await?;
if n == 0 {
break;
}
digest.update(&buffer[..n]);
}
Ok(Some(format!("{:x}", digest.finalize())))
}
async fn read_json<T: serde::de::DeserializeOwned>(
root: &Path,
name: &str,
) -> crate::Result<Option<T>> {
match tokio::fs::read(target(root, name)?).await {
Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
fn write_json<T: Serialize>(
root: &Path,
name: &str,
value: &T,
) -> crate::Result<()> {
use std::io::Write;
let dest = target(root, name)?;
let mut file = tempfile::NamedTempFile::new_in(root)?;
file.write_all(&serde_json::to_vec(value)?)?;
file.as_file().sync_all()?;
file.persist(dest).map_err(|e| e.error)?;
Ok(())
}
async fn request<T: serde::de::DeserializeOwned>(
suffix: &str,
) -> crate::Result<T> {
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,
)
.await?;
Ok(response.payload)
}
pub async fn catalog() -> crate::Result<Vec<Publication>> {
request("").await
}
pub async fn binding(instance_id: &str) -> crate::Result<Option<Binding>> {
read_json(&crate::instance::get_full_path(instance_id).await?, BINDING)
.await
}
pub async fn java_arguments(instance_id: &str) -> crate::Result<Vec<String>> {
Ok(binding(instance_id)
.await?
.map(|b| b.publication.manifest.java_arguments)
.unwrap_or_default())
}
pub async fn game_arguments(instance_id: &str) -> crate::Result<Vec<String>> {
Ok(binding(instance_id)
.await?
.map(|b| b.publication.manifest.game_arguments)
.unwrap_or_default())
}
#[derive(Serialize, Deserialize)]
struct Action {
path: String,
old_hash: Option<String>,
next_hash: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct Journal {
backup: String,
actions: Vec<Action>,
previous: Option<Binding>,
metadata: crate::state::InstanceMetadata,
}
async fn apply_files(
root: &Path,
cache: &Path,
backup_dir: &str,
actions: &[Action],
) -> crate::Result<()> {
for action in actions {
let live = target(root, &action.path)?;
if hash(&live).await? != action.old_hash {
return Err(invalid(format!(
"File changed during sync: {}",
action.path
)));
}
if action.old_hash.is_some() {
let backup =
target(root, &format!("{}/{}", backup_dir, action.path))?;
tokio::fs::create_dir_all(backup.parent().unwrap()).await?;
tokio::fs::rename(&live, backup).await?;
}
if let Some(next) = &action.next_hash {
tokio::fs::create_dir_all(live.parent().unwrap()).await?;
let staged =
tempfile::NamedTempFile::new_in(live.parent().unwrap())?;
tokio::fs::copy(target(cache, next)?, staged.path()).await?;
staged.as_file().sync_all()?;
staged.persist(&live).map_err(|e| e.error)?;
}
}
Ok(())
}
async fn restore_files(
root: &Path,
backup_dir: &str,
actions: &[Action],
) -> crate::Result<()> {
// A durable journal is written before touching live files; it also recovers interrupted runs.
if !backup_dir.starts_with(".starlight-pack-backup-")
|| backup_dir.contains(['/', '\\'])
{
return Err(invalid("Invalid modpack recovery directory"));
}
for action in actions.iter().rev() {
safe_path(&action.path)?;
let live = target(root, &action.path)?;
let backup = target(root, &format!("{}/{}", backup_dir, action.path))?;
if backup.is_file() {
if live.exists() {
tokio::fs::remove_file(&live).await?;
}
tokio::fs::create_dir_all(live.parent().unwrap()).await?;
tokio::fs::rename(backup, live).await?;
} else if action.old_hash.is_none()
&& action.next_hash.is_some()
&& hash(&live).await? == action.next_hash
{
tokio::fs::remove_file(live).await?;
}
}
Ok(())
}
async fn restore(root: &Path, journal: &Journal) -> crate::Result<()> {
restore_files(root, &journal.backup, &journal.actions).await?;
let state = State::get().await?;
crate::state::instances::commands::restore_instance_metadata(
&journal.metadata,
&state.pool,
)
.await?;
if let Some(old) = &journal.previous {
write_json(root, BINDING, old)?;
} else if target(root, BINDING)?.exists() {
tokio::fs::remove_file(target(root, BINDING)?).await?;
}
tokio::fs::remove_file(target(root, JOURNAL)?).await?;
Ok(())
}
async fn ensure_idle(instance_id: &str) -> crate::Result<()> {
if !crate::process::get_by_instance_id(instance_id)
.await?
.is_empty()
{
return Err(invalid(
"Close Minecraft before synchronizing its modpack",
));
}
if crate::install::list_jobs(false).await?.iter().any(|job| {
job.instance_id.as_deref() == Some(instance_id)
&& matches!(
job.status,
crate::install::InstallJobStatus::Running
| crate::install::InstallJobStatus::Canceling
| crate::install::InstallJobStatus::Queued
| crate::install::InstallJobStatus::WaitingForUser
)
}) {
return Err(invalid("An installation is already using this instance"));
}
Ok(())
}
async fn recover(instance_id: &str) -> crate::Result<()> {
let root = crate::instance::get_full_path(instance_id).await?;
if let Some(journal) = read_json::<Journal>(&root, JOURNAL).await? {
if journal.metadata.instance.id != instance_id {
return Err(invalid(
"Recovery journal belongs to another instance",
));
}
ensure_idle(instance_id).await?;
restore(&root, &journal).await?;
}
Ok(())
}
pub async fn synchronize(
instance_id: &str,
pack_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
}
fn effective_mode(
explicit: Option<InstanceMode>,
has_binding: bool,
) -> InstanceMode {
explicit.unwrap_or(if has_binding {
InstanceMode::StarLight
} else {
InstanceMode::Local
})
}
pub async fn instance_mode(instance_id: &str) -> crate::Result<InstanceMode> {
let metadata = crate::instance::get(instance_id)
.await?
.ok_or_else(|| invalid("Unknown instance"))?;
if let Some(mode) = metadata.launch_overrides.instance_mode {
return Ok(mode);
}
Ok(effective_mode(None, binding(instance_id).await?.is_some()))
}
pub async fn set_instance_mode(
instance_id: &str,
mode: InstanceMode,
) -> crate::Result<()> {
let _guard = instance_gate(instance_id).lock_owned().await;
ensure_idle(instance_id).await?;
recover(instance_id).await?;
let metadata = crate::instance::get(instance_id)
.await?
.ok_or_else(|| invalid("Unknown instance"))?;
if mode == InstanceMode::StarLight
&& (metadata.instance.linked_launcher.is_some()
|| metadata.instance.symlink_target.is_some()
|| !matches!(
metadata.link,
crate::state::InstanceLink::Unmanaged
| crate::state::InstanceLink::ImportedModpack { .. }
))
{
return Err(invalid(
"外部关联、共享目录或由第三方整合包平台管理的实例不能开启 StarLight 自动同步,请创建独立实例",
));
}
crate::instance::edit(
instance_id,
EditInstance {
launch_overrides: Some(InstanceLaunchOverridesPatch {
instance_mode: Some(mode),
..Default::default()
}),
..Default::default()
},
)
.await?;
Ok(())
}
/// Held through process creation so a manual sync cannot race a launch.
pub async fn prepare_launch(
instance_id: &str,
offline: bool,
) -> crate::Result<OwnedMutexGuard<()>> {
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?;
}
}
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"))?;
ensure_idle(instance_id).await?;
recover(instance_id).await?;
let metadata = crate::instance::get(instance_id)
.await?
.ok_or_else(|| invalid("Unknown instance"))?;
if metadata.instance.linked_launcher.is_some()
|| metadata.instance.symlink_target.is_some()
|| !matches!(
metadata.link,
crate::state::InstanceLink::Unmanaged
| crate::state::InstanceLink::ImportedModpack { .. }
)
{
return Err(invalid(
"Hosted modpacks require a launcher-managed instance",
));
}
let root = crate::instance::get_full_path(instance_id).await?;
let previous: Option<Binding> = read_json(&root, BINDING).await?;
if 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
{
return Err(invalid("Invalid modpack publication"));
}
if previous
.as_ref()
.is_some_and(|b| 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 {
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)
};
let runtime_changed = metadata.applied_content_set.game_version
!= manifest.runtime.game_version
|| metadata.applied_content_set.loader != loader
|| metadata.applied_content_set.loader_version != resolved_loader;
let cache = target(&root, ".starlight-pack-cache")?;
tokio::fs::create_dir_all(&cache).await?;
let state = State::get().await?;
let mut files = manifest.files.clone();
validate(&files)?;
let mut downloaded = 0;
let mut sources = BTreeMap::new();
for f in &files {
sources.insert(
f.path.clone(),
format!("{API}/files/{}/{}", publication.release_id, f.sha256),
);
}
// Resolve CurseForge references using the launcher's existing API and integrity checks.
for external in &manifest.external {
let cf = crate::api::curseforge::get_file(
external.project_id,
external.file_id,
)
.await?;
if cf.mod_id != external.project_id || cf.id != external.file_id {
return Err(invalid("CurseForge returned a mismatched file"));
}
safe_path(&cf.file_name)?;
if cf.file_name.contains('/') {
return Err(invalid("Invalid CurseForge filename"));
}
let path = format!("mods/{}", cf.file_name);
if files.iter().any(|f| f.path.eq_ignore_ascii_case(&path)) {
continue;
} // overrides take precedence
let sha1 = cf
.hashes
.iter()
.find(|h| h.algo == 1)
.map(|h| h.value.clone())
.ok_or_else(|| invalid("CurseForge file has no SHA-1"))?;
let staged = target(
&cache,
&format!("cf-{}-{}", external.project_id, external.file_id),
)?;
let local = target(&root, &path)?;
if !staged.exists()
&& local.is_file()
&& crate::util::fetch::sha1_file_async(&local).await?
== (cf.file_length, sha1.clone())
{
tokio::fs::copy(&local, &staged).await?;
}
if !staged.exists() {
let url = match cf.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")
})?,
};
download_to_path(
DownloadRequest::new(url, ResourceClass::CurseForge)
.with_integrity(
Integrity::sha1(sha1.clone()).with_size(cf.file_length),
),
&staged,
&state.fetch_semaphore,
&state.pool,
None,
)
.await?;
downloaded += cf.file_length;
} else if crate::util::fetch::sha1_file_async(&staged).await?
!= (cf.file_length, sha1)
{
return Err(invalid("Cached CurseForge file failed verification"));
}
let sha256 = hash(&staged)
.await?
.ok_or_else(|| invalid("CurseForge download is missing"))?;
let object = target(&cache, &sha256)?;
if !object.exists() {
tokio::fs::copy(&staged, object).await?;
}
files.push(PackFile {
path,
sha256,
size: cf.file_length,
force: true,
preserve: false,
});
}
validate(&files)?;
if let Some(old) = &previous {
validate(&old.files)?;
}
let old: BTreeMap<_, _> = previous
.as_ref()
.map(|b| b.files.iter().map(|f| (f.path.as_str(), f)).collect())
.unwrap_or_default();
let mut actions = Vec::new();
let mut preserved = Vec::new();
let next_paths: BTreeMap<_, _> = files
.iter()
.map(|f| (f.path.to_ascii_lowercase(), f.path.as_str()))
.collect();
for prior in old.values() {
if next_paths
.get(&prior.path.to_ascii_lowercase())
.is_some_and(|next| **next != prior.path)
{
return Err(invalid(
"Case-only filename changes require a new instance",
));
}
}
for f in &files {
let live = target(&root, &f.path)?;
let local = hash(&live).await?;
if local.as_deref() == Some(&f.sha256) {
continue;
}
let prior = old.get(f.path.as_str());
if local.is_some()
&& (f.preserve
|| (!f.force
&& prior
.is_some_and(|p| local.as_deref() != Some(&p.sha256))))
{
preserved.push(f.path.clone());
continue;
}
if previous.is_none() && local.is_some() {
return Err(invalid(format!(
"Existing file conflicts with the modpack: {}. Use an empty instance.",
f.path
)));
}
let object = target(&cache, &f.sha256)?;
if hash(&object).await?.as_deref() != Some(&f.sha256) {
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;
}
actions.push(Action {
path: f.path.clone(),
old_hash: local,
next_hash: Some(f.sha256.clone()),
});
}
for prior in old.values() {
if !next_paths.contains_key(&prior.path.to_ascii_lowercase()) {
let local = hash(&target(&root, &prior.path)?).await?;
if local.is_none() {
continue;
}
if prior.preserve || local.as_deref() != Some(&prior.sha256) {
preserved.push(prior.path.clone());
continue;
}
actions.push(Action {
path: prior.path.clone(),
old_hash: local,
next_hash: None,
});
}
}
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)
{
return Ok(SyncResult {
instance_id: instance_id.into(),
version: manifest.version.clone(),
downloaded_bytes: downloaded,
changed_files: 0,
preserved_files: preserved,
});
}
let journal = Journal {
backup: format!(".starlight-pack-backup-{}", uuid::Uuid::new_v4()),
actions,
previous,
metadata,
};
write_json(&root, JOURNAL, &journal)?;
let result = async {
// 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),
content_set_patch: Some(AppliedContentSetPatch { game_version: Some(manifest.runtime.game_version.clone()), loader: Some(loader), loader_version: Some(resolved_loader.clone()), ..Default::default() }),
..Default::default()
}).await?;
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 {
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")); }
}
}
}
apply_files(&root, &cache, &journal.backup, &journal.actions).await?;
write_json(&root, BINDING, &Binding { publication: publication.clone(), files })?;
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 {
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);
}
Ok(SyncResult {
instance_id: instance_id.to_string(),
version: publication.manifest.version,
downloaded_bytes: downloaded,
changed_files: journal.actions.len(),
preserved_files: preserved,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn instance_operations_exclude_each_other_without_blocking_other_instances()
{
let first = instance_gate("mode-test-first");
let held = first.lock_owned().await;
assert!(instance_gate("mode-test-first").try_lock_owned().is_err());
assert!(instance_gate("mode-test-second").try_lock_owned().is_ok());
drop(held);
assert!(instance_gate("mode-test-first").try_lock_owned().is_ok());
}
#[test]
fn explicit_local_disables_legacy_binding_sync() {
assert_eq!(effective_mode(None, false), InstanceMode::Local);
assert_eq!(effective_mode(None, true), InstanceMode::StarLight);
assert_eq!(
effective_mode(Some(InstanceMode::Local), true),
InstanceMode::Local
);
assert_eq!(
effective_mode(Some(InstanceMode::StarLight), false),
InstanceMode::StarLight
);
}
#[tokio::test]
async fn partial_update_recovers_modified_added_and_removed_files() {
let directory = tempfile::tempdir().unwrap();
let root = directory.path();
let cache = root.join(".starlight-pack-cache");
std::fs::create_dir_all(&cache).unwrap();
std::fs::write(root.join("old.jar"), b"old").unwrap();
std::fs::write(root.join("removed.jar"), b"removed").unwrap();
std::fs::write(cache.join("next"), b"next").unwrap();
let next = hash(&cache.join("next")).await.unwrap().unwrap();
std::fs::rename(cache.join("next"), cache.join(&next)).unwrap();
let actions = vec![
Action {
path: "old.jar".into(),
old_hash: hash(&root.join("old.jar")).await.unwrap(),
next_hash: Some(next.clone()),
},
Action {
path: "new.jar".into(),
old_hash: None,
next_hash: Some(next.clone()),
},
Action {
path: "removed.jar".into(),
old_hash: hash(&root.join("removed.jar")).await.unwrap(),
next_hash: None,
},
Action {
path: "missing.jar".into(),
old_hash: None,
next_hash: Some("0".repeat(64)),
},
];
let backup = ".starlight-pack-backup-test";
assert!(apply_files(root, &cache, backup, &actions).await.is_err());
assert_eq!(hash(&root.join("old.jar")).await.unwrap(), Some(next));
assert!(!root.join("removed.jar").exists());
restore_files(root, backup, &actions).await.unwrap();
assert_eq!(std::fs::read(root.join("old.jar")).unwrap(), b"old");
assert_eq!(
std::fs::read(root.join("removed.jar")).unwrap(),
b"removed"
);
assert!(!root.join("new.jar").exists());
assert!(!root.join("missing.jar").exists());
restore_files(root, backup, &actions).await.unwrap();
}
#[test]
fn rejects_cross_platform_escape_paths() {
for path in [
"../mods/a",
"/mods/a",
"C:/a",
"mods\\a",
"mods/NUL.jar",
"mods/a.",
"mods//a",
".starlight-pack.json",
] {
assert!(safe_path(path).is_err(), "{path}");
}
assert!(safe_path("mods/中文.jar").is_ok());
}
#[test]
fn rejects_case_and_ancestor_collisions() {
let make = |p: &str| PackFile {
path: p.into(),
sha256: "a".repeat(64),
size: 1,
force: true,
preserve: false,
};
assert!(validate(&[make("mods/a.jar"), make("mods/A.jar")]).is_err());
assert!(validate(&[make("config/a"), make("config/a/b")]).is_err());
}
}

View File

@ -142,6 +142,7 @@ pub(crate) async fn import_axolotl(
loader_version: Some(config.content_set.loader_version.clone()),
}),
launch_overrides: Some(InstanceLaunchOverridesPatch {
instance_mode: Some(crate::state::InstanceMode::Local),
java_path: Some(config.launch_overrides.java_path.clone()),
extra_launch_args: Some(
config.launch_overrides.extra_launch_args.clone(),

View File

@ -1,6 +1,7 @@
pub(crate) mod archive_util;
pub mod detect;
pub mod import;
pub mod hosted;
pub mod install_from;
pub(crate) mod install_hmcl;
pub(crate) mod install_mcbbs;