feat: 添加获批整合包同步与 StarLight 实例模式
This commit is contained in:
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
884
packages/app-lib/src/api/pack/hosted.rs
Normal file
884
packages/app-lib/src/api/pack/hosted.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1525,6 +1525,10 @@ pub async fn launch_minecraft(
|
||||
) -> crate::Result<ProcessMetadata> {
|
||||
let instance = &context.instance;
|
||||
let content_set = &context.applied_content_set;
|
||||
let mut combined_java_args =
|
||||
crate::pack::hosted::java_arguments(&instance.id).await?;
|
||||
combined_java_args.extend_from_slice(java_args);
|
||||
let java_args = combined_java_args.as_slice();
|
||||
|
||||
if instance.install_stage == InstanceInstallStage::PackInstalling
|
||||
|| instance.install_stage == InstanceInstallStage::MinecraftInstalling
|
||||
@ -1917,7 +1921,8 @@ pub async fn launch_minecraft(
|
||||
let mut effective_memory = *memory;
|
||||
let mut effective_resolution = *resolution;
|
||||
// Extra game arguments appended after the vanilla game arguments.
|
||||
let mut extra_game_args: Vec<String> = Vec::new();
|
||||
let mut extra_game_args =
|
||||
crate::pack::hosted::game_arguments(&instance.id).await?;
|
||||
if let Some(direct) = direct_launch.as_ref()
|
||||
&& direct.dialect == LinkedLauncherDialect::Hmcl
|
||||
{
|
||||
|
||||
@ -125,8 +125,10 @@ pub(crate) async fn create_instance(
|
||||
created: now,
|
||||
modified: now,
|
||||
};
|
||||
let launch_overrides =
|
||||
let mut launch_overrides =
|
||||
InstanceLaunchOverrides::empty(instance_id.clone());
|
||||
launch_overrides.instance_mode =
|
||||
Some(crate::state::InstanceMode::Local);
|
||||
let loader_components = LoaderComponent::from_legacy_projection(
|
||||
instance_id.clone(),
|
||||
input.loader,
|
||||
|
||||
@ -54,6 +54,7 @@ pub struct EditInstance {
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct InstanceLaunchOverridesPatch {
|
||||
pub instance_mode: Option<crate::state::InstanceMode>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
@ -369,6 +370,9 @@ fn apply_launch_overrides_patch(
|
||||
if let Some(game_resolution) = patch.game_resolution {
|
||||
overrides.game_resolution = game_resolution;
|
||||
}
|
||||
if let Some(mode) = patch.instance_mode {
|
||||
overrides.instance_mode = Some(mode);
|
||||
}
|
||||
if let Some(timeout) = patch.launch_preparation_timeout {
|
||||
overrides.launch_preparation_timeout = timeout;
|
||||
}
|
||||
|
||||
@ -3,8 +3,21 @@ use crate::state::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstanceMode {
|
||||
#[serde(rename = "starlight")]
|
||||
StarLight,
|
||||
#[default]
|
||||
Local,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceLaunchOverrides {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub instance_mode: Option<InstanceMode>,
|
||||
pub instance_id: String,
|
||||
pub java_path: Option<String>,
|
||||
pub extra_launch_args: Option<Vec<String>>,
|
||||
@ -21,6 +34,7 @@ pub struct InstanceLaunchOverrides {
|
||||
impl InstanceLaunchOverrides {
|
||||
pub fn empty(instance_id: String) -> Self {
|
||||
Self {
|
||||
instance_mode: None,
|
||||
instance_id,
|
||||
java_path: None,
|
||||
extra_launch_args: None,
|
||||
@ -41,6 +55,8 @@ impl InstanceLaunchOverrides {
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct InstanceLaunchOverridesData {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub instance_mode: Option<InstanceMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub java_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@ -67,6 +83,7 @@ impl InstanceLaunchOverridesData {
|
||||
instance_id: String,
|
||||
) -> InstanceLaunchOverrides {
|
||||
InstanceLaunchOverrides {
|
||||
instance_mode: self.instance_mode,
|
||||
instance_id,
|
||||
java_path: self.java_path,
|
||||
extra_launch_args: self.extra_launch_args,
|
||||
@ -84,6 +101,7 @@ impl InstanceLaunchOverridesData {
|
||||
impl From<&InstanceLaunchOverrides> for InstanceLaunchOverridesData {
|
||||
fn from(overrides: &InstanceLaunchOverrides) -> Self {
|
||||
Self {
|
||||
instance_mode: overrides.instance_mode,
|
||||
java_path: overrides.java_path.clone(),
|
||||
extra_launch_args: overrides.extra_launch_args.clone(),
|
||||
custom_env_vars: overrides.custom_env_vars.clone(),
|
||||
@ -104,3 +122,36 @@ pub struct InstanceLaunchContext {
|
||||
pub link: InstanceLink,
|
||||
pub launch_overrides: InstanceLaunchOverrides,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn instance_mode_round_trips_with_saved_launch_configuration() {
|
||||
let mut original = InstanceLaunchOverrides::empty("instance".into());
|
||||
for mode in [InstanceMode::StarLight, InstanceMode::Local] {
|
||||
original.instance_mode = Some(mode);
|
||||
let encoded = serde_json::to_string(
|
||||
&InstanceLaunchOverridesData::from(&original),
|
||||
)
|
||||
.unwrap();
|
||||
let decoded: InstanceLaunchOverridesData =
|
||||
serde_json::from_str(&encoded).unwrap();
|
||||
assert_eq!(
|
||||
decoded
|
||||
.into_launch_overrides("instance".into())
|
||||
.instance_mode,
|
||||
Some(mode)
|
||||
);
|
||||
}
|
||||
let old: InstanceLaunchOverridesData =
|
||||
serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(old.instance_mode, None);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&InstanceMode::StarLight).unwrap(),
|
||||
"\"starlight\""
|
||||
);
|
||||
assert!(serde_json::from_str::<InstanceMode>("\"invalid\"").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@ -611,6 +611,7 @@ where
|
||||
}
|
||||
|
||||
let launch_overrides = InstanceLaunchOverrides {
|
||||
instance_mode: None,
|
||||
instance_id: instance_id.clone(),
|
||||
java_path: input.java_path,
|
||||
extra_launch_args: input.extra_launch_args,
|
||||
|
||||
Reference in New Issue
Block a user