feat:移除了弹窗,服务器添加sls
This commit is contained in:
commit
6a295f9a7a
4082 files changed
+1322534
No files matched your search
@@ -0,0 +1,370 @@
|
||||
use crate::state::ProjectType;
|
||||
use crate::util::io::IOError;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ScannedContentFile {
|
||||
pub relative_path: String,
|
||||
pub file_name: String,
|
||||
pub enabled: bool,
|
||||
pub size: u64,
|
||||
pub modified: Option<SystemTime>,
|
||||
pub hash_cache_key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ScannedBackupFile {
|
||||
pub relative_path: String,
|
||||
pub file_name: String,
|
||||
/// Seconds since the UNIX epoch; used to pick the oldest backup when
|
||||
/// several updates of the same file have accumulated.
|
||||
pub modified: i64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn scan_content_files(
|
||||
instances_dir: &Path,
|
||||
instance_path: &str,
|
||||
) -> crate::Result<Vec<ScannedContentFile>> {
|
||||
let instance_dir =
|
||||
crate::util::io::canonicalize(instances_dir.join(instance_path))?;
|
||||
scan_content_files_from(&instance_dir, instance_path)
|
||||
}
|
||||
|
||||
/// Variant of [`scan_content_files`] that takes an already-resolved game
|
||||
/// directory, so callers can pass a `game_dir_override` target as-is.
|
||||
/// `cache_key_path` is the *relative* instance path used as the hash-cache-key
|
||||
/// prefix (it must stay the launcher's relative `instance.path`, not the
|
||||
/// resolved override path, so content hashes remain stable across dirs).
|
||||
pub(crate) fn scan_content_files_from(
|
||||
instance_dir: &Path,
|
||||
cache_key_path: &str,
|
||||
) -> crate::Result<Vec<ScannedContentFile>> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
for_each_content_folder(
|
||||
instance_dir,
|
||||
|folder_path, relative_dir, project_type| {
|
||||
scan_content_folder(
|
||||
folder_path,
|
||||
relative_dir,
|
||||
project_type,
|
||||
cache_key_path,
|
||||
&mut files,
|
||||
)
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Collects update backup files (`*.old`) across every content folder. Backups
|
||||
/// are never hashed or listed as content; they are matched back to their
|
||||
/// active file by the `{active}_{previous}.old` naming convention.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn scan_content_backups(
|
||||
instances_dir: &Path,
|
||||
instance_path: &str,
|
||||
) -> crate::Result<Vec<ScannedBackupFile>> {
|
||||
scan_content_backups_from(&crate::util::io::canonicalize(
|
||||
instances_dir.join(instance_path),
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Variant of [`scan_content_backups`] that takes an already-resolved game
|
||||
/// directory, so callers can pass a `game_dir_override` target as-is.
|
||||
pub(crate) fn scan_content_backups_from(
|
||||
instance_dir: &Path,
|
||||
) -> crate::Result<Vec<ScannedBackupFile>> {
|
||||
let mut backups = Vec::new();
|
||||
|
||||
for_each_content_folder(instance_dir, |folder_path, relative_dir, _| {
|
||||
for entry in std::fs::read_dir(folder_path)
|
||||
.map_err(|err| IOError::with_path(err, folder_path))?
|
||||
{
|
||||
let path = entry.map_err(IOError::from)?.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(file_name) =
|
||||
path.file_name().and_then(|value| value.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !file_name.ends_with(".old") {
|
||||
continue;
|
||||
}
|
||||
let modified = path
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.modified())
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
.unwrap_or_default();
|
||||
backups.push(ScannedBackupFile {
|
||||
relative_path: format!("{relative_dir}/{file_name}"),
|
||||
file_name: file_name.to_string(),
|
||||
modified,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(backups)
|
||||
}
|
||||
|
||||
/// Walks every content folder (and, for schematics, nested subfolders) and
|
||||
/// invokes `visit` for each folder that may hold project files.
|
||||
fn for_each_content_folder(
|
||||
instance_dir: &Path,
|
||||
mut visit: impl FnMut(&Path, &str, ProjectType) -> crate::Result<()>,
|
||||
) -> crate::Result<()> {
|
||||
for project_type in ProjectType::iterator() {
|
||||
let folder = project_type.get_folder();
|
||||
let folder_path = instance_dir.join(folder);
|
||||
|
||||
if !folder_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
walk_content_folder(&folder_path, folder, project_type, &mut visit)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn walk_content_folder(
|
||||
folder_path: &Path,
|
||||
relative_dir: &str,
|
||||
project_type: ProjectType,
|
||||
visit: &mut impl FnMut(&Path, &str, ProjectType) -> crate::Result<()>,
|
||||
) -> crate::Result<()> {
|
||||
visit(folder_path, relative_dir, project_type)?;
|
||||
|
||||
for entry in std::fs::read_dir(folder_path)
|
||||
.map_err(|err| IOError::with_path(err, folder_path))?
|
||||
{
|
||||
let path = entry.map_err(IOError::from)?.path();
|
||||
if path.is_dir() {
|
||||
// Only schematics may live in nested folders; other content
|
||||
// folders are scanned at the top level only.
|
||||
if project_type == ProjectType::Schematic {
|
||||
let Some(dir_name) =
|
||||
path.file_name().and_then(|value| value.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
walk_content_folder(
|
||||
&path,
|
||||
&format!("{relative_dir}/{dir_name}"),
|
||||
project_type,
|
||||
visit,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scan_content_folder(
|
||||
folder_path: &Path,
|
||||
relative_dir: &str,
|
||||
project_type: ProjectType,
|
||||
instance_path: &str,
|
||||
files: &mut Vec<ScannedContentFile>,
|
||||
) -> crate::Result<()> {
|
||||
for entry in std::fs::read_dir(folder_path)
|
||||
.map_err(|err| IOError::with_path(err, folder_path))?
|
||||
{
|
||||
let path = entry.map_err(IOError::from)?.path();
|
||||
if path.is_dir() || !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(file_name) = path.file_name().and_then(|value| value.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !is_scannable_project_path(project_type, file_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = path.metadata().map_err(IOError::from)?;
|
||||
let size = metadata.len();
|
||||
let relative_path = format!("{relative_dir}/{file_name}");
|
||||
let hash_cache_key = format!("{size}-{instance_path}/{relative_path}");
|
||||
|
||||
files.push(ScannedContentFile {
|
||||
relative_path,
|
||||
file_name: file_name.to_string(),
|
||||
enabled: !file_name.ends_with(".disabled"),
|
||||
size,
|
||||
modified: metadata.modified().ok(),
|
||||
hash_cache_key,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn project_type_from_relative_path(
|
||||
relative_path: &str,
|
||||
) -> Option<ProjectType> {
|
||||
let mut current = Path::new(relative_path).parent();
|
||||
while let Some(parent) = current {
|
||||
let folder_name = parent
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or_default();
|
||||
if let Some(project_type) = ProjectType::from_folder_name(folder_name) {
|
||||
return Some(project_type);
|
||||
}
|
||||
current = parent.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn is_scannable_project_path(
|
||||
project_type: ProjectType,
|
||||
relative_path: &str,
|
||||
) -> bool {
|
||||
let Some(extension) =
|
||||
Path::new(relative_path.trim_end_matches(".disabled"))
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match project_type {
|
||||
ProjectType::Mod => extension.eq_ignore_ascii_case("jar"),
|
||||
ProjectType::DataPack
|
||||
| ProjectType::ResourcePack
|
||||
| ProjectType::ShaderPack => extension.eq_ignore_ascii_case("zip"),
|
||||
ProjectType::Schematic => {
|
||||
extension.eq_ignore_ascii_case("litematic")
|
||||
|| extension.eq_ignore_ascii_case("schematic")
|
||||
|| extension.eq_ignore_ascii_case("schem")
|
||||
}
|
||||
// WorldSave folders (saves/) are handled separately via worlds.rs,
|
||||
// not scanned as regular project files.
|
||||
ProjectType::WorldSave => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn scan_content_files_finds_nested_schematics() {
|
||||
let root = tempdir().unwrap();
|
||||
let instance_dir = root.path().join("inst");
|
||||
let schematics = instance_dir.join("schematics");
|
||||
fs::create_dir_all(schematics.join("redstone/contraptions")).unwrap();
|
||||
fs::create_dir_all(instance_dir.join("mods")).unwrap();
|
||||
fs::write(schematics.join("house.litematic"), "a").unwrap();
|
||||
fs::write(schematics.join("redstone/clock.litematic"), "b").unwrap();
|
||||
fs::write(schematics.join("redstone/contraptions/gear.schem"), "c")
|
||||
.unwrap();
|
||||
fs::write(schematics.join("redstone/notes.txt"), "d").unwrap();
|
||||
fs::write(instance_dir.join("mods/example.jar"), "e").unwrap();
|
||||
|
||||
let files = scan_content_files(root.path(), "inst").unwrap();
|
||||
|
||||
let paths: Vec<&str> = files
|
||||
.iter()
|
||||
.map(|file| file.relative_path.as_str())
|
||||
.collect();
|
||||
assert!(paths.contains(&"schematics/house.litematic"));
|
||||
assert!(paths.contains(&"schematics/redstone/clock.litematic"));
|
||||
assert!(paths.contains(&"schematics/redstone/contraptions/gear.schem"));
|
||||
assert!(!paths.contains(&"schematics/redstone/notes.txt"));
|
||||
assert!(paths.contains(&"mods/example.jar"));
|
||||
assert_eq!(files.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_type_from_relative_path_matches_nested_folders() {
|
||||
assert_eq!(
|
||||
project_type_from_relative_path("schematics/house.litematic"),
|
||||
Some(ProjectType::Schematic)
|
||||
);
|
||||
assert_eq!(
|
||||
project_type_from_relative_path(
|
||||
"schematics/redstone/clock.litematic"
|
||||
),
|
||||
Some(ProjectType::Schematic)
|
||||
);
|
||||
assert_eq!(
|
||||
project_type_from_relative_path("schematics/a/b/c/tower.litematic"),
|
||||
Some(ProjectType::Schematic)
|
||||
);
|
||||
assert_eq!(
|
||||
project_type_from_relative_path("mods/example.jar"),
|
||||
Some(ProjectType::Mod)
|
||||
);
|
||||
assert_eq!(
|
||||
project_type_from_relative_path("config/example.json"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scannable_project_paths_exclude_shader_configuration_sidecars() {
|
||||
assert!(is_scannable_project_path(
|
||||
ProjectType::ShaderPack,
|
||||
"shaderpacks/BSL_v10.1.3.zip"
|
||||
));
|
||||
assert!(!is_scannable_project_path(
|
||||
ProjectType::ShaderPack,
|
||||
"shaderpacks/BSL_v10.1.3.zip.txt"
|
||||
));
|
||||
assert!(!is_scannable_project_path(
|
||||
ProjectType::ShaderPack,
|
||||
"shaderpacks/ComplementaryReimagined_r5.3.txt"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_content_backups_finds_only_backup_files() {
|
||||
let root = tempdir().unwrap();
|
||||
let instance_dir = root.path().join("inst");
|
||||
fs::create_dir_all(instance_dir.join("schematics/redstone")).unwrap();
|
||||
fs::create_dir_all(instance_dir.join("mods")).unwrap();
|
||||
fs::write(instance_dir.join("mods/Mod-2.jar"), "new").unwrap();
|
||||
fs::write(instance_dir.join("mods/Mod-2.jar_Mod-1.jar.old"), "old")
|
||||
.unwrap();
|
||||
fs::write(
|
||||
instance_dir
|
||||
.join("schematics/redstone/New.litematic_Old.litematic.old"),
|
||||
"x",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
instance_dir.join("schematics/redstone/house.litematic"),
|
||||
"y",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let backups = scan_content_backups(root.path(), "inst").unwrap();
|
||||
let mut paths = backups
|
||||
.iter()
|
||||
.map(|backup| backup.relative_path.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
paths.sort_unstable();
|
||||
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![
|
||||
"mods/Mod-2.jar_Mod-1.jar.old",
|
||||
"schematics/redstone/New.litematic_Old.litematic.old",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub(crate) mod filesystem;
|
||||
pub(crate) mod sqlite;
|
||||
@@ -0,0 +1,130 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use chrono::Utc;
|
||||
use sqlx::{Executor, Sqlite, SqlitePool};
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub(crate) struct InstanceConfigSyncRow {
|
||||
pub instance_id: String,
|
||||
pub path: String,
|
||||
pub config_updated_at: Option<i64>,
|
||||
pub generated_at: Option<i64>,
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_config_updated_at<'e, E>(
|
||||
instance_id: &str,
|
||||
exec: E,
|
||||
) -> crate::Result<()>
|
||||
where
|
||||
E: Executor<'e, Database = Sqlite>,
|
||||
{
|
||||
let now = Utc::now().timestamp();
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO instance_config_sync_state (
|
||||
instance_id,
|
||||
config_updated_at
|
||||
)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT (instance_id) DO UPDATE SET
|
||||
config_updated_at = excluded.config_updated_at
|
||||
",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.bind(now)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_all_config_dirty<'e, E>(exec: E) -> crate::Result<()>
|
||||
where
|
||||
E: Executor<'e, Database = Sqlite>,
|
||||
{
|
||||
let now = Utc::now().timestamp();
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
UPDATE instance_config_sync_state
|
||||
SET config_updated_at = ?
|
||||
",
|
||||
)
|
||||
.bind(now)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn get_config_sync_generated_at<'e, E>(
|
||||
instance_id: &str,
|
||||
exec: E,
|
||||
) -> crate::Result<Option<i64>>
|
||||
where
|
||||
E: Executor<'e, Database = Sqlite>,
|
||||
{
|
||||
let generated_at: Option<Option<i64>> = sqlx::query_scalar(
|
||||
"
|
||||
SELECT generated_at
|
||||
FROM instance_config_sync_state
|
||||
WHERE instance_id = ?
|
||||
",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
Ok(generated_at.flatten())
|
||||
}
|
||||
|
||||
pub(crate) async fn update_config_sync_generated_at<'e, E>(
|
||||
instance_id: &str,
|
||||
generated_at: i64,
|
||||
exec: E,
|
||||
) -> crate::Result<()>
|
||||
where
|
||||
E: Executor<'e, Database = Sqlite>,
|
||||
{
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO instance_config_sync_state (
|
||||
instance_id,
|
||||
config_updated_at,
|
||||
generated_at
|
||||
)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT (instance_id) DO UPDATE SET
|
||||
generated_at = excluded.generated_at
|
||||
",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.bind(generated_at)
|
||||
.bind(generated_at)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_instance_config_sync_rows(
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Vec<InstanceConfigSyncRow>> {
|
||||
let rows = sqlx::query_as::<_, InstanceConfigSyncRow>(
|
||||
"
|
||||
SELECT
|
||||
i.id AS instance_id,
|
||||
i.path AS path,
|
||||
s.config_updated_at AS config_updated_at,
|
||||
s.generated_at AS generated_at
|
||||
FROM instances i
|
||||
LEFT JOIN instance_config_sync_state s
|
||||
ON s.instance_id = i.id
|
||||
",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,170 @@
|
||||
use crate::state::instances::{
|
||||
LoaderComponent, LoaderComponentKind, LoaderComponentRole,
|
||||
};
|
||||
use sqlx::{FromRow, Sqlite, SqlitePool, Transaction};
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct LoaderComponentRow {
|
||||
instance_id: String,
|
||||
kind: String,
|
||||
version: Option<String>,
|
||||
role: String,
|
||||
provider_metadata: Option<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<LoaderComponentRow> for LoaderComponent {
|
||||
type Error = crate::Error;
|
||||
|
||||
fn try_from(row: LoaderComponentRow) -> crate::Result<Self> {
|
||||
let provider_metadata = row
|
||||
.provider_metadata
|
||||
.map(|value| serde_json::from_str(&value))
|
||||
.transpose()
|
||||
.map_err(|err| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Invalid loader provider metadata: {err}"
|
||||
))
|
||||
.as_error()
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
instance_id: row.instance_id,
|
||||
kind: LoaderComponentKind::from_str(&row.kind)?,
|
||||
version: row.version,
|
||||
role: LoaderComponentRole::from_str(&row.role)?,
|
||||
provider_metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn list_loader_components(
|
||||
instance_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Vec<LoaderComponent>> {
|
||||
let rows = sqlx::query_as::<_, LoaderComponentRow>(
|
||||
"SELECT instance_id, kind, version, role, provider_metadata
|
||||
FROM instance_loader_components
|
||||
WHERE instance_id = ?
|
||||
ORDER BY CASE role WHEN 'primary' THEN 0 ELSE 1 END, kind",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn replace_loader_components(
|
||||
instance_id: &str,
|
||||
components: &[LoaderComponent],
|
||||
tx: &mut Transaction<'_, Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
sqlx::query("DELETE FROM instance_loader_components WHERE instance_id = ?")
|
||||
.bind(instance_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
for component in components {
|
||||
if component.instance_id != instance_id {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Loader component {} belongs to a different instance",
|
||||
component.kind.as_str()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let provider_metadata = component
|
||||
.provider_metadata
|
||||
.as_ref()
|
||||
.map(serde_json::to_string)
|
||||
.transpose()?;
|
||||
sqlx::query(
|
||||
"INSERT INTO instance_loader_components (
|
||||
instance_id, kind, version, role, provider_metadata
|
||||
) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.bind(component.kind.as_str())
|
||||
.bind(&component.version)
|
||||
.bind(component.role.as_str())
|
||||
.bind(provider_metadata)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::{LoaderComponentKind, ModLoader};
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
#[tokio::test]
|
||||
async fn component_rows_round_trip_and_cascade() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("PRAGMA foreign_keys = ON")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::raw_sql(
|
||||
"CREATE TABLE instances (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE instance_loader_components (
|
||||
instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL,
|
||||
version TEXT,
|
||||
role TEXT NOT NULL,
|
||||
provider_metadata TEXT,
|
||||
PRIMARY KEY(instance_id, kind)
|
||||
);",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO instances(id) VALUES ('instance')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let components = vec![
|
||||
LoaderComponent::new_primary(
|
||||
"instance",
|
||||
ModLoader::Forge,
|
||||
Some("47.4.0".to_string()),
|
||||
),
|
||||
LoaderComponent {
|
||||
instance_id: "instance".to_string(),
|
||||
kind: LoaderComponentKind::OptiFine,
|
||||
version: Some("HD_U_I6".to_string()),
|
||||
role: LoaderComponentRole::Adjunct,
|
||||
provider_metadata: Some(
|
||||
serde_json::json!({ "source": "bmclapi" }),
|
||||
),
|
||||
},
|
||||
];
|
||||
let mut tx = pool.begin().await.unwrap();
|
||||
replace_loader_components("instance", &components, &mut tx)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
list_loader_components("instance", &pool).await.unwrap(),
|
||||
components
|
||||
);
|
||||
|
||||
sqlx::query("DELETE FROM instances WHERE id = 'instance'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
list_loader_components("instance", &pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub(crate) mod config_sync_rows;
|
||||
pub(crate) mod content_rows;
|
||||
pub(crate) mod instance_rows;
|
||||
pub(crate) mod loader_component_rows;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,894 @@
|
||||
use crate::state::instances::{
|
||||
ContentEntry, ContentSet, ContentSourceKind, InstanceFile,
|
||||
adapters::sqlite::{content_rows, instance_rows},
|
||||
};
|
||||
use crate::state::{
|
||||
CacheBehaviour, CachedEntry, ContentProviderRef, Dependency,
|
||||
DependencyType, ModrinthVersionId, ProjectType, State, Version,
|
||||
};
|
||||
use crate::util::fetch::DownloadReason;
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::apply_content_install::{
|
||||
DownloadedProjectVersion, add_downloaded_project_version,
|
||||
add_project_from_version, add_resolved_content, archive_project_file,
|
||||
content_ownership_for_path, download_project_version,
|
||||
persist_resolved_plan_dependency_edges, remove_project,
|
||||
resolve_content_scope, resolve_install_plan, toggle_disable_project,
|
||||
};
|
||||
use super::check_content_updates::{ContentUpdate, check_content_updates};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct BulkUpdatePlan {
|
||||
project_updates: Vec<PlannedProjectUpdate>,
|
||||
dependency_additions: Vec<PlannedDependencyInstall>,
|
||||
curseforge_updates: Vec<ContentUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PlannedProjectUpdate {
|
||||
relative_path: String,
|
||||
project_id: String,
|
||||
current_version_id: String,
|
||||
update_version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct PlannedDependencyInstall {
|
||||
project_id: String,
|
||||
version_id: String,
|
||||
parent_version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum PlannedDownload {
|
||||
ProjectUpdate(PlannedProjectUpdate),
|
||||
DependencyAddition(PlannedDependencyInstall),
|
||||
}
|
||||
|
||||
enum DownloadedBulkProject {
|
||||
ProjectUpdate(PlannedProjectUpdate, DownloadedProjectVersion),
|
||||
DependencyAddition(PlannedDependencyInstall, DownloadedProjectVersion),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct AppliedBulkItem {
|
||||
project_id: String,
|
||||
version_id: String,
|
||||
relative_path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct InstalledProject {
|
||||
relative_path: String,
|
||||
project_id: Option<String>,
|
||||
version_id: Option<String>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ResolvedDependency {
|
||||
project_id: String,
|
||||
version_id: String,
|
||||
parent_version_id: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn update_project(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<String> {
|
||||
let updates = check_content_updates(
|
||||
instance_id,
|
||||
Some(CacheBehaviour::MustRevalidate),
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
let update = updates
|
||||
.into_iter()
|
||||
.find(|update| update.relative_path() == project_path)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"This project cannot be updated!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
apply_content_update(instance_id, project_path, &update, state).await
|
||||
}
|
||||
|
||||
async fn apply_content_update(
|
||||
instance_id: &str,
|
||||
project_path: &str,
|
||||
update: &ContentUpdate,
|
||||
state: &State,
|
||||
) -> crate::Result<String> {
|
||||
let mut new_path = match update {
|
||||
ContentUpdate::Modrinth {
|
||||
project_id,
|
||||
current_version_id,
|
||||
update_version_id,
|
||||
..
|
||||
} => {
|
||||
let version = CachedEntry::get_version(
|
||||
update_version_id,
|
||||
Some(CacheBehaviour::MustRevalidate),
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Unable to install version id {}. Not found.",
|
||||
update_version_id
|
||||
))
|
||||
})?;
|
||||
let content_type =
|
||||
ProjectType::get_from_loaders(version.loaders.clone())
|
||||
.map(modrinth_content_management::ContentType::from)
|
||||
.unwrap_or(modrinth_content_management::ContentType::Mod);
|
||||
let plan = resolve_install_plan(
|
||||
instance_id,
|
||||
super::apply_content_install::InstanceInstallProjectRequest {
|
||||
project_id: project_id.to_string(),
|
||||
version_id: Some(update_version_id.to_string()),
|
||||
content_type,
|
||||
selected: Default::default(),
|
||||
excluded_project_ids: Vec::new(),
|
||||
force_project_ids: Vec::new(),
|
||||
},
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
let ownership_kind =
|
||||
content_ownership_for_path(instance_id, project_path, state)
|
||||
.await?;
|
||||
let mut paths = Vec::with_capacity(plan.dependencies.len() + 1);
|
||||
paths.push(
|
||||
add_project_from_version(
|
||||
instance_id,
|
||||
&plan.primary.version_id,
|
||||
DownloadReason::Update,
|
||||
Some(current_version_id.to_string()),
|
||||
ContentSourceKind::Local,
|
||||
ownership_kind,
|
||||
state,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
for dependency in &plan.dependencies {
|
||||
paths.push(
|
||||
add_resolved_content(
|
||||
instance_id,
|
||||
dependency,
|
||||
DownloadReason::Dependency,
|
||||
true,
|
||||
state,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
persist_resolved_plan_dependency_edges(
|
||||
instance_id,
|
||||
&paths,
|
||||
&plan,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
paths.remove(0)
|
||||
}
|
||||
ContentUpdate::CurseForge { .. } => {
|
||||
let result = crate::api::curseforge::update_installed_file(
|
||||
instance_id,
|
||||
project_path,
|
||||
)
|
||||
.await?;
|
||||
result
|
||||
.installed
|
||||
.into_iter()
|
||||
.find(|file| !file.dependency)
|
||||
.map(|file| file.relative_path)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"CurseForge update did not produce an installed file"
|
||||
.to_string(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
if project_path.ends_with(".disabled") {
|
||||
new_path =
|
||||
toggle_disable_project(instance_id, &new_path, Some(false), state)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if new_path != project_path {
|
||||
if archive_project_file(instance_id, project_path, &new_path, state)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
remove_project(instance_id, project_path, state).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(new_path)
|
||||
}
|
||||
|
||||
pub(crate) async fn update_all_projects(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<HashMap<String, String>> {
|
||||
emit_bulk_update_progress(
|
||||
instance_id,
|
||||
crate::event::InstanceBulkUpdateProgressStage::ResolvingVersions,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
.await?;
|
||||
let plan = plan_bulk_update(instance_id, state).await?;
|
||||
let download_total =
|
||||
plan.project_updates.len() + plan.dependency_additions.len();
|
||||
let downloads =
|
||||
download_planned_projects(instance_id, &plan, download_total, state)
|
||||
.await?;
|
||||
|
||||
let mut changed = HashMap::new();
|
||||
let mut applied = Vec::<AppliedBulkItem>::new();
|
||||
emit_bulk_update_progress(
|
||||
instance_id,
|
||||
crate::event::InstanceBulkUpdateProgressStage::Finishing,
|
||||
download_total,
|
||||
download_total,
|
||||
)
|
||||
.await?;
|
||||
for download in downloads {
|
||||
match download {
|
||||
DownloadedBulkProject::ProjectUpdate(update, downloaded) => {
|
||||
let mut new_path = add_downloaded_project_version(
|
||||
instance_id,
|
||||
downloaded,
|
||||
ContentSourceKind::Local,
|
||||
crate::state::instances::ContentOwnershipKind::UserAdded,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if update.relative_path.ends_with(".disabled") {
|
||||
new_path = toggle_disable_project(
|
||||
instance_id,
|
||||
&new_path,
|
||||
Some(false),
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if new_path != update.relative_path {
|
||||
if archive_project_file(
|
||||
instance_id,
|
||||
&update.relative_path,
|
||||
&new_path,
|
||||
state,
|
||||
)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
remove_project(
|
||||
instance_id,
|
||||
&update.relative_path,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
applied.push(AppliedBulkItem {
|
||||
project_id: update.project_id,
|
||||
version_id: update.update_version_id,
|
||||
relative_path: new_path.clone(),
|
||||
});
|
||||
changed.insert(update.relative_path, new_path);
|
||||
}
|
||||
DownloadedBulkProject::DependencyAddition(
|
||||
dependency,
|
||||
downloaded,
|
||||
) => {
|
||||
let new_path = add_downloaded_project_version(
|
||||
instance_id,
|
||||
downloaded,
|
||||
ContentSourceKind::Local,
|
||||
crate::state::instances::ContentOwnershipKind::UserAdded,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
let scope =
|
||||
resolve_content_scope(instance_id, None, state).await?;
|
||||
if let Some(entry) =
|
||||
content_rows::get_content_entry_by_relative_path(
|
||||
&scope.content_set_id,
|
||||
&new_path,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
content_rows::set_content_entry_auto_dependency(
|
||||
&entry.id,
|
||||
true,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
applied.push(AppliedBulkItem {
|
||||
project_id: dependency.project_id,
|
||||
version_id: dependency.version_id,
|
||||
relative_path: new_path,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for update in &plan.curseforge_updates {
|
||||
let relative_path = update.relative_path().to_string();
|
||||
let new_path =
|
||||
apply_content_update(instance_id, &relative_path, update, state)
|
||||
.await?;
|
||||
changed.insert(relative_path, new_path);
|
||||
}
|
||||
|
||||
persist_bulk_dependency_edges(
|
||||
instance_id,
|
||||
&applied,
|
||||
&plan.dependency_additions,
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
async fn persist_bulk_dependency_edges(
|
||||
instance_id: &str,
|
||||
applied: &[AppliedBulkItem],
|
||||
dependency_additions: &[PlannedDependencyInstall],
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
if dependency_additions.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let _instance_lock = state.lock_instance_content(instance_id).await;
|
||||
let scope = resolve_content_scope(instance_id, None, state).await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
for dependency in dependency_additions {
|
||||
let Some(parent) = applied
|
||||
.iter()
|
||||
.find(|item| item.version_id == dependency.parent_version_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(child) = applied
|
||||
.iter()
|
||||
.find(|item| item.version_id == dependency.version_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(parent_entry) =
|
||||
content_rows::get_content_entry_by_relative_path(
|
||||
&scope.content_set_id,
|
||||
&parent.relative_path,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(child_entry) =
|
||||
content_rows::get_content_entry_by_relative_path(
|
||||
&scope.content_set_id,
|
||||
&child.relative_path,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let now = chrono::Utc::now();
|
||||
content_rows::upsert_content_dependency_edge_in_transaction(
|
||||
&crate::state::instances::ContentDependencyEdge {
|
||||
id: format!("content-dependency:{}", uuid::Uuid::new_v4()),
|
||||
content_set_id: scope.content_set_id.clone(),
|
||||
parent_entry_id: parent_entry.id,
|
||||
child_entry_id: child_entry.id,
|
||||
evidence_provider: crate::state::ContentProvider::Modrinth,
|
||||
parent_provider: crate::state::ContentProvider::Modrinth,
|
||||
child_provider: crate::state::ContentProvider::Modrinth,
|
||||
dependency_kind:
|
||||
crate::state::instances::ContentDependencyKind::Required,
|
||||
parent_project_id: parent.project_id.clone(),
|
||||
parent_release_id: parent.version_id.clone(),
|
||||
child_project_id: child.project_id.clone(),
|
||||
child_release_id: child.version_id.clone(),
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
},
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_planned_projects(
|
||||
instance_id: &str,
|
||||
plan: &BulkUpdatePlan,
|
||||
total: usize,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<DownloadedBulkProject>> {
|
||||
emit_bulk_update_progress(
|
||||
instance_id,
|
||||
crate::event::InstanceBulkUpdateProgressStage::Downloading,
|
||||
0,
|
||||
total,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut downloads = plan
|
||||
.project_updates
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(PlannedDownload::ProjectUpdate)
|
||||
.chain(
|
||||
plan.dependency_additions
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(PlannedDownload::DependencyAddition),
|
||||
)
|
||||
.map(|download| async move {
|
||||
match download {
|
||||
PlannedDownload::ProjectUpdate(update) => {
|
||||
let downloaded = download_project_version(
|
||||
instance_id,
|
||||
&update.update_version_id,
|
||||
DownloadReason::Update,
|
||||
Some(update.current_version_id.clone()),
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok::<_, crate::Error>(DownloadedBulkProject::ProjectUpdate(
|
||||
update, downloaded,
|
||||
))
|
||||
}
|
||||
PlannedDownload::DependencyAddition(dependency) => {
|
||||
let downloaded = download_project_version(
|
||||
instance_id,
|
||||
&dependency.version_id,
|
||||
DownloadReason::Dependency,
|
||||
Some(dependency.parent_version_id.clone()),
|
||||
state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok::<_, crate::Error>(
|
||||
DownloadedBulkProject::DependencyAddition(
|
||||
dependency, downloaded,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
let mut completed = 0;
|
||||
let mut output = Vec::with_capacity(total);
|
||||
|
||||
while let Some(download) = downloads.next().await {
|
||||
let download = download?;
|
||||
completed += 1;
|
||||
emit_bulk_update_progress(
|
||||
instance_id,
|
||||
crate::event::InstanceBulkUpdateProgressStage::Downloading,
|
||||
completed,
|
||||
total,
|
||||
)
|
||||
.await?;
|
||||
output.push(download);
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
async fn emit_bulk_update_progress(
|
||||
instance_id: &str,
|
||||
stage: crate::event::InstanceBulkUpdateProgressStage,
|
||||
current: usize,
|
||||
total: usize,
|
||||
) -> crate::Result<()> {
|
||||
crate::event::emit::emit_instance_bulk_update_progress(
|
||||
crate::event::InstanceBulkUpdateProgressPayload {
|
||||
instance_id: instance_id.to_string(),
|
||||
stage,
|
||||
current,
|
||||
total,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn plan_bulk_update(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<BulkUpdatePlan> {
|
||||
let updateable_paths =
|
||||
bulk_updateable_project_paths(instance_id, state).await?;
|
||||
if updateable_paths.is_empty() {
|
||||
return Ok(BulkUpdatePlan {
|
||||
project_updates: Vec::new(),
|
||||
dependency_additions: Vec::new(),
|
||||
curseforge_updates: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let updates = check_content_updates(
|
||||
instance_id,
|
||||
Some(CacheBehaviour::MustRevalidate),
|
||||
state,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|update| updateable_paths.contains(update.relative_path()))
|
||||
.collect::<Vec<_>>();
|
||||
if updates.is_empty() {
|
||||
return Ok(BulkUpdatePlan {
|
||||
project_updates: Vec::new(),
|
||||
dependency_additions: Vec::new(),
|
||||
curseforge_updates: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let content_set =
|
||||
content_rows::get_applied_content_set(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Instance {instance_id} has no applied content set"
|
||||
))
|
||||
})?;
|
||||
let installed =
|
||||
installed_projects(instance_id, &content_set, state).await?;
|
||||
let installed_by_project = installed
|
||||
.iter()
|
||||
.filter_map(|project| {
|
||||
project
|
||||
.project_id
|
||||
.as_ref()
|
||||
.map(|project_id| (project_id.clone(), project.clone()))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let updates_by_path = updates
|
||||
.iter()
|
||||
.filter_map(|update| {
|
||||
let (_, current, target) = update.modrinth_ids()?;
|
||||
Some((
|
||||
update.relative_path().to_string(),
|
||||
(current.to_string(), target.to_string()),
|
||||
))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let version_ids = installed
|
||||
.iter()
|
||||
.filter(|project| updateable_paths.contains(&project.relative_path))
|
||||
.filter_map(|project| project.version_id.clone())
|
||||
.chain(updates.iter().filter_map(|update| {
|
||||
update
|
||||
.modrinth_ids()
|
||||
.map(|(_, _, target)| target.to_string())
|
||||
}))
|
||||
.collect::<HashSet<_>>();
|
||||
let version_id_refs = version_ids
|
||||
.iter()
|
||||
.map(|id| ModrinthVersionId::new(id.clone()))
|
||||
.collect::<crate::Result<Vec<_>>>()?;
|
||||
let versions = CachedEntry::get_version_many(
|
||||
&version_id_refs,
|
||||
Some(CacheBehaviour::MustRevalidate),
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
let versions_by_id = versions
|
||||
.into_iter()
|
||||
.map(|version| (version.id.clone(), version))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let planned_versions = installed
|
||||
.iter()
|
||||
.filter(|project| project.enabled)
|
||||
.filter(|project| updateable_paths.contains(&project.relative_path))
|
||||
.filter_map(|project| {
|
||||
let target_version_id = updates_by_path
|
||||
.get(&project.relative_path)
|
||||
.map(|(_, update_version_id)| update_version_id)
|
||||
.map(|version_id| version_id.as_str())
|
||||
.or(project.version_id.as_deref())?;
|
||||
|
||||
versions_by_id.get(target_version_id).cloned()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let planned_dependencies =
|
||||
dependency_closure(planned_versions, &content_set, state).await?;
|
||||
let dependency_additions = planned_dependencies
|
||||
.values()
|
||||
.filter(|dependency| {
|
||||
!installed_by_project.contains_key(&dependency.project_id)
|
||||
})
|
||||
.map(|dependency| PlannedDependencyInstall {
|
||||
project_id: dependency.project_id.clone(),
|
||||
version_id: dependency.version_id.clone(),
|
||||
parent_version_id: dependency.parent_version_id.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let curseforge_updates = updates
|
||||
.iter()
|
||||
.filter(|update| matches!(update, ContentUpdate::CurseForge { .. }))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let project_updates = updates
|
||||
.into_iter()
|
||||
.filter_map(|update| {
|
||||
let (project_id, current, target) = update.modrinth_ids()?;
|
||||
Some(PlannedProjectUpdate {
|
||||
relative_path: update.relative_path().to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
current_version_id: current.to_string(),
|
||||
update_version_id: target.to_string(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(BulkUpdatePlan {
|
||||
project_updates,
|
||||
dependency_additions,
|
||||
curseforge_updates,
|
||||
})
|
||||
}
|
||||
|
||||
async fn bulk_updateable_project_paths(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<HashSet<String>> {
|
||||
let paths = sqlx::query_scalar::<_, String>(
|
||||
"SELECT file.relative_path
|
||||
FROM instance_content_entries entry
|
||||
INNER JOIN instance_files file ON file.id = entry.file_id
|
||||
INNER JOIN instances instance ON instance.id = entry.instance_id
|
||||
WHERE instance.id = ?
|
||||
AND entry.content_set_id = instance.applied_content_set_id
|
||||
AND entry.ownership_kind = 'user_added'
|
||||
AND file.missing = 0",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(paths.into_iter().collect())
|
||||
}
|
||||
|
||||
async fn installed_projects(
|
||||
instance_id: &str,
|
||||
content_set: &ContentSet,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<InstalledProject>> {
|
||||
let instance = instance_rows::get_instance_by_id(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
let entries =
|
||||
content_rows::get_content_entries(&content_set.id, &state.pool).await?;
|
||||
let entries_by_file_id = entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
entry.file_id.as_deref().map(|file_id| (file_id, entry))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let files =
|
||||
content_rows::get_instance_files(&instance.id, &state.pool).await?;
|
||||
|
||||
let mut installed = Vec::new();
|
||||
for file in files {
|
||||
let Some(entry) = entries_by_file_id.get(file.id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let refs =
|
||||
content_rows::get_content_provider_refs(&entry.id, &state.pool)
|
||||
.await?;
|
||||
if let Some(project) = installed_project_from_row(&file, entry, &refs) {
|
||||
installed.push(project);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(installed)
|
||||
}
|
||||
|
||||
fn installed_project_from_row(
|
||||
file: &InstanceFile,
|
||||
entry: &ContentEntry,
|
||||
provider_refs: &[ContentProviderRef],
|
||||
) -> Option<InstalledProject> {
|
||||
let (project_id, version_id) =
|
||||
provider_refs.iter().find_map(|reference| match reference {
|
||||
ContentProviderRef::Modrinth {
|
||||
project_id,
|
||||
version_id: Some(version_id),
|
||||
} => Some((
|
||||
Some(project_id.to_string()),
|
||||
Some(version_id.to_string()),
|
||||
)),
|
||||
_ => None,
|
||||
})?;
|
||||
|
||||
Some(InstalledProject {
|
||||
relative_path: file.relative_path.clone(),
|
||||
project_id,
|
||||
version_id,
|
||||
enabled: entry.enabled && file.enabled,
|
||||
})
|
||||
}
|
||||
|
||||
async fn dependency_closure(
|
||||
root_versions: Vec<Version>,
|
||||
content_set: &ContentSet,
|
||||
state: &State,
|
||||
) -> crate::Result<HashMap<String, ResolvedDependency>> {
|
||||
let mut output = HashMap::new();
|
||||
let mut stack = root_versions;
|
||||
let mut visited_versions = HashSet::new();
|
||||
let mut version_cache = HashMap::new();
|
||||
let mut project_versions_cache = HashMap::new();
|
||||
|
||||
while let Some(version) = stack.pop() {
|
||||
if !visited_versions.insert(version.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for dependency in &version.dependencies {
|
||||
if !is_required_dependency(dependency, content_set) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(dependency_version) = resolve_dependency_version(
|
||||
dependency,
|
||||
content_set,
|
||||
state,
|
||||
&mut version_cache,
|
||||
&mut project_versions_cache,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let project_id = dependency
|
||||
.project_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| dependency_version.project_id.clone());
|
||||
|
||||
output.entry(project_id.clone()).or_insert_with(|| {
|
||||
ResolvedDependency {
|
||||
project_id,
|
||||
version_id: dependency_version.id.clone(),
|
||||
parent_version_id: version.id.clone(),
|
||||
}
|
||||
});
|
||||
stack.push(dependency_version);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn is_required_dependency(
|
||||
dependency: &Dependency,
|
||||
content_set: &ContentSet,
|
||||
) -> bool {
|
||||
matches!(dependency.dependency_type, DependencyType::Required)
|
||||
&& !(dependency.project_id.as_deref() == Some("P7dR8mSH")
|
||||
&& content_set.loader.as_str() == "quilt")
|
||||
}
|
||||
|
||||
async fn resolve_dependency_version(
|
||||
dependency: &Dependency,
|
||||
content_set: &ContentSet,
|
||||
state: &State,
|
||||
version_cache: &mut HashMap<String, Option<Version>>,
|
||||
project_versions_cache: &mut HashMap<String, Option<Vec<Version>>>,
|
||||
) -> crate::Result<Option<Version>> {
|
||||
if let Some(version_id) = &dependency.version_id {
|
||||
return cached_version(version_id, version_cache, state).await;
|
||||
}
|
||||
|
||||
let Some(project_id) = &dependency.project_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(mut versions) =
|
||||
cached_project_versions(project_id, project_versions_cache, state)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
versions.sort_by_key(|version| Reverse(version.date_published));
|
||||
|
||||
Ok(find_preferred_dependency_version(&versions, content_set))
|
||||
}
|
||||
|
||||
async fn cached_version(
|
||||
version_id: &str,
|
||||
version_cache: &mut HashMap<String, Option<Version>>,
|
||||
state: &State,
|
||||
) -> crate::Result<Option<Version>> {
|
||||
if !version_cache.contains_key(version_id) {
|
||||
let version = CachedEntry::get_version(
|
||||
&ModrinthVersionId::new(version_id.to_string())?,
|
||||
Some(CacheBehaviour::MustRevalidate),
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
version_cache.insert(version_id.to_string(), version);
|
||||
}
|
||||
|
||||
Ok(version_cache.get(version_id).cloned().flatten())
|
||||
}
|
||||
|
||||
async fn cached_project_versions(
|
||||
project_id: &str,
|
||||
project_versions_cache: &mut HashMap<String, Option<Vec<Version>>>,
|
||||
state: &State,
|
||||
) -> crate::Result<Option<Vec<Version>>> {
|
||||
if !project_versions_cache.contains_key(project_id) {
|
||||
let versions = CachedEntry::get_project_versions(
|
||||
&crate::state::ModrinthProjectId::new(project_id.to_string())?,
|
||||
Some(CacheBehaviour::MustRevalidate),
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
project_versions_cache.insert(project_id.to_string(), versions);
|
||||
}
|
||||
|
||||
Ok(project_versions_cache.get(project_id).cloned().flatten())
|
||||
}
|
||||
|
||||
fn find_preferred_dependency_version(
|
||||
versions: &[Version],
|
||||
content_set: &ContentSet,
|
||||
) -> Option<Version> {
|
||||
versions
|
||||
.iter()
|
||||
.find(|version| {
|
||||
version.game_versions.contains(&content_set.game_version)
|
||||
&& version
|
||||
.loaders
|
||||
.iter()
|
||||
.any(|loader| loader == content_set.loader.as_str())
|
||||
})
|
||||
.or_else(|| {
|
||||
versions.iter().find(|version| {
|
||||
is_dependency_version_compatible(version, content_set)
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn is_dependency_version_compatible(
|
||||
version: &Version,
|
||||
content_set: &ContentSet,
|
||||
) -> bool {
|
||||
version.game_versions.contains(&content_set.game_version)
|
||||
&& (version
|
||||
.loaders
|
||||
.iter()
|
||||
.any(|loader| loader == content_set.loader.as_str())
|
||||
|| version.loaders.iter().any(|loader| loader == "datapack"))
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
use crate::state::instances::{
|
||||
ContentEntry, ContentSet, Instance, InstanceFile,
|
||||
adapters::sqlite::{content_rows, instance_rows},
|
||||
};
|
||||
use crate::state::{
|
||||
CacheBehaviour, CachedEntry, ContentProvider, ContentProviderRef,
|
||||
CurseForgeFileId, ModrinthProjectId, ModrinthVersionId, ProjectType,
|
||||
ReleaseChannel, State,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::sync_content_files::{
|
||||
fetch_content_file_updates, installed_modrinth_version_id,
|
||||
modrinth_update_enabled, project_type_for_file,
|
||||
sync_instance_content_files,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum ContentUpdate {
|
||||
Modrinth {
|
||||
relative_path: String,
|
||||
project_id: ModrinthProjectId,
|
||||
current_version_id: ModrinthVersionId,
|
||||
update_version_id: ModrinthVersionId,
|
||||
},
|
||||
CurseForge {
|
||||
relative_path: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ContentUpdate {
|
||||
pub fn relative_path(&self) -> &str {
|
||||
match self {
|
||||
Self::Modrinth { relative_path, .. }
|
||||
| Self::CurseForge { relative_path, .. } => relative_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn modrinth_ids(
|
||||
&self,
|
||||
) -> Option<(&ModrinthProjectId, &ModrinthVersionId, &ModrinthVersionId)>
|
||||
{
|
||||
match self {
|
||||
Self::Modrinth {
|
||||
project_id,
|
||||
current_version_id,
|
||||
update_version_id,
|
||||
..
|
||||
} => Some((project_id, current_version_id, update_version_id)),
|
||||
Self::CurseForge { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct UpdateCandidate {
|
||||
entry: Option<ContentEntry>,
|
||||
file: InstanceFile,
|
||||
project_type: ProjectType,
|
||||
project_id: ModrinthProjectId,
|
||||
current_version_id: ModrinthVersionId,
|
||||
}
|
||||
|
||||
pub(crate) async fn check_content_updates(
|
||||
instance_id: &str,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<ContentUpdate>> {
|
||||
let context = load_installed_content(instance_id, state).await?;
|
||||
let candidates =
|
||||
modrinth_update_candidates(&context, cache_behaviour, state).await?;
|
||||
let mut output =
|
||||
resolve_modrinth_updates(&context, &candidates, cache_behaviour, state)
|
||||
.await?;
|
||||
output.extend(resolve_curseforge_updates(&context, state).await?);
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Everything about an instance's currently applied content, loaded once and
|
||||
/// shared across the update-check phases.
|
||||
struct InstalledContentContext {
|
||||
instance: Instance,
|
||||
content_set: ContentSet,
|
||||
files: Vec<InstanceFile>,
|
||||
files_by_id: HashMap<String, InstanceFile>,
|
||||
entries_by_file_id: HashMap<String, ContentEntry>,
|
||||
provider_refs_by_file_id: HashMap<String, Vec<ContentProviderRef>>,
|
||||
origin_provider_by_file_id: HashMap<String, Option<ContentProvider>>,
|
||||
}
|
||||
|
||||
/// Phase 1 — load the instance's applied content set and file/provider state.
|
||||
async fn load_installed_content(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<InstalledContentContext> {
|
||||
let instance = instance_rows::get_instance_by_id(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
let files = sync_instance_content_files(&instance, state).await?;
|
||||
let _instance_lock = state.lock_instance_content(instance_id).await;
|
||||
let content_set =
|
||||
content_rows::get_applied_content_set(&instance.id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Instance {} has no applied content set",
|
||||
instance.id
|
||||
))
|
||||
})?;
|
||||
let entries =
|
||||
content_rows::get_content_entries(&content_set.id, &state.pool).await?;
|
||||
let entries_by_file_id = entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
entry
|
||||
.file_id
|
||||
.as_deref()
|
||||
.map(|file_id| (file_id.to_string(), entry.clone()))
|
||||
})
|
||||
.collect();
|
||||
let files_by_id = files
|
||||
.iter()
|
||||
.map(|file| (file.id.clone(), file.clone()))
|
||||
.collect();
|
||||
let mut provider_refs_by_file_id = HashMap::new();
|
||||
let mut origin_provider_by_file_id = HashMap::new();
|
||||
for entry in &entries {
|
||||
let Some(file_id) = entry.file_id.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
provider_refs_by_file_id.insert(
|
||||
file_id.to_string(),
|
||||
content_rows::get_content_provider_refs(&entry.id, &state.pool)
|
||||
.await?,
|
||||
);
|
||||
origin_provider_by_file_id.insert(
|
||||
file_id.to_string(),
|
||||
content_rows::get_content_origin_provider(&entry.id, &state.pool)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(InstalledContentContext {
|
||||
instance,
|
||||
content_set,
|
||||
files,
|
||||
files_by_id,
|
||||
entries_by_file_id,
|
||||
provider_refs_by_file_id,
|
||||
origin_provider_by_file_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Phase 2 — resolve the Modrinth version of every update-enabled file and
|
||||
/// build an `UpdateCandidate` for each one that maps to a known project.
|
||||
async fn modrinth_update_candidates(
|
||||
context: &InstalledContentContext,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<UpdateCandidate>> {
|
||||
let hashes = context
|
||||
.files
|
||||
.iter()
|
||||
.filter(|file| {
|
||||
modrinth_update_enabled(
|
||||
context
|
||||
.origin_provider_by_file_id
|
||||
.get(&file.id)
|
||||
.and_then(|provider| *provider),
|
||||
context
|
||||
.provider_refs_by_file_id
|
||||
.get(&file.id)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.map(|file| file.sha1.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let file_info = CachedEntry::get_file_many(
|
||||
&hashes,
|
||||
cache_behaviour,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
let file_info_by_hash = file_info
|
||||
.into_iter()
|
||||
.map(|file| (file.hash.clone(), file))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut candidates = Vec::new();
|
||||
for file in &context.files {
|
||||
if !modrinth_update_enabled(
|
||||
context
|
||||
.origin_provider_by_file_id
|
||||
.get(&file.id)
|
||||
.and_then(|provider| *provider),
|
||||
context
|
||||
.provider_refs_by_file_id
|
||||
.get(&file.id)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default(),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let Some(metadata) = file_info_by_hash.get(&file.sha1) else {
|
||||
continue;
|
||||
};
|
||||
let Some(project_type) = project_type_for_file(file) else {
|
||||
continue;
|
||||
};
|
||||
let provider_refs = context
|
||||
.provider_refs_by_file_id
|
||||
.get(&file.id)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default();
|
||||
let project_id = ModrinthProjectId::new(metadata.project_id.clone())?;
|
||||
let current_version_id = installed_modrinth_version_id(provider_refs)
|
||||
.unwrap_or(ModrinthVersionId::new(metadata.version_id.clone())?);
|
||||
candidates.push(UpdateCandidate {
|
||||
entry: context.entries_by_file_id.get(&file.id).cloned(),
|
||||
file: file.clone(),
|
||||
project_type,
|
||||
project_id,
|
||||
current_version_id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
/// Phase 3 — query available Modrinth updates for the candidates, persist the
|
||||
/// check result, and collect the differing versions.
|
||||
async fn resolve_modrinth_updates(
|
||||
context: &InstalledContentContext,
|
||||
candidates: &[UpdateCandidate],
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<ContentUpdate>> {
|
||||
let mut output = Vec::new();
|
||||
if candidates.is_empty() {
|
||||
return Ok(output);
|
||||
}
|
||||
|
||||
let installed_channels =
|
||||
installed_update_channels(candidates, cache_behaviour, state).await?;
|
||||
let update_keys = candidates
|
||||
.iter()
|
||||
.map(|candidate| {
|
||||
update_cache_key(
|
||||
&candidate.file,
|
||||
candidate.project_type,
|
||||
effective_update_channel(
|
||||
context.instance.update_channel,
|
||||
installed_channels.get(&candidate.file.sha1).copied(),
|
||||
),
|
||||
&context.content_set.game_version,
|
||||
context.content_set.loader.as_str(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let update_key_refs = update_keys
|
||||
.iter()
|
||||
.map(|key| key.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let updates = fetch_content_file_updates(
|
||||
&update_key_refs,
|
||||
cache_behaviour,
|
||||
true,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
let mut updates_by_hash: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for update in updates {
|
||||
updates_by_hash
|
||||
.entry(update.hash)
|
||||
.or_default()
|
||||
.push(update.update_version_id);
|
||||
}
|
||||
|
||||
let _instance_lock =
|
||||
state.lock_instance_content(&context.instance.id).await;
|
||||
|
||||
for candidate in candidates {
|
||||
let update_version_id = updates_by_hash
|
||||
.remove(&candidate.file.sha1)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|update_version_id| {
|
||||
update_version_id != candidate.current_version_id.as_str()
|
||||
})
|
||||
.map(ModrinthVersionId::new)
|
||||
.transpose()?;
|
||||
|
||||
if let Some(entry) = &candidate.entry {
|
||||
content_rows::upsert_content_update_check(
|
||||
&entry.id,
|
||||
context.instance.update_channel,
|
||||
Some(ContentProvider::Modrinth),
|
||||
Some(candidate.project_id.as_str()),
|
||||
update_version_id.as_ref().map(ModrinthVersionId::as_str),
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(update_version_id) = update_version_id {
|
||||
output.push(ContentUpdate::Modrinth {
|
||||
relative_path: candidate.file.relative_path.clone(),
|
||||
project_id: candidate.project_id.clone(),
|
||||
current_version_id: candidate.current_version_id.clone(),
|
||||
update_version_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Phase 3 — resolve CurseForge updates for origin-CurseForge content.
|
||||
async fn resolve_curseforge_updates(
|
||||
context: &InstalledContentContext,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<ContentUpdate>> {
|
||||
let mut output = Vec::new();
|
||||
if crate::api::curseforge::capability().status
|
||||
!= crate::api::curseforge::CurseForgeCapabilityStatus::Ready
|
||||
{
|
||||
return Ok(output);
|
||||
}
|
||||
let _instance_lock =
|
||||
state.lock_instance_content(&context.instance.id).await;
|
||||
let mut target_files_by_project = HashMap::<u32, Option<u32>>::new();
|
||||
for (file_id, refs) in &context.provider_refs_by_file_id {
|
||||
if context
|
||||
.origin_provider_by_file_id
|
||||
.get(file_id)
|
||||
.and_then(|provider| *provider)
|
||||
!= Some(ContentProvider::CurseForge)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(ContentProviderRef::CurseForge {
|
||||
project_id,
|
||||
file_id: Some(current_file_id),
|
||||
}) = refs.iter().find(|reference| {
|
||||
matches!(reference, ContentProviderRef::CurseForge { .. })
|
||||
})
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(instance_file) = context.files_by_id.get(file_id) else {
|
||||
continue;
|
||||
};
|
||||
let Some(project_type) = project_type_for_file(instance_file) else {
|
||||
continue;
|
||||
};
|
||||
let target_file_id =
|
||||
match target_files_by_project.get(&project_id.get()) {
|
||||
Some(target_file_id) => *target_file_id,
|
||||
None => {
|
||||
let target_file_id =
|
||||
crate::api::curseforge::select_latest_compatible_file(
|
||||
project_id.get(),
|
||||
Some(context.content_set.game_version.clone()),
|
||||
(project_type == ProjectType::Mod)
|
||||
.then(|| {
|
||||
curseforge_loader_type(
|
||||
context.content_set.loader.as_str(),
|
||||
)
|
||||
})
|
||||
.flatten(),
|
||||
Some(context.instance.update_channel),
|
||||
)
|
||||
.await?
|
||||
.map(|file| file.id);
|
||||
target_files_by_project
|
||||
.insert(project_id.get(), target_file_id);
|
||||
target_file_id
|
||||
}
|
||||
};
|
||||
let Some(target_file_id) = target_file_id else {
|
||||
continue;
|
||||
};
|
||||
if target_file_id == current_file_id.get() {
|
||||
continue;
|
||||
}
|
||||
let target_file_id = CurseForgeFileId::new(target_file_id)?;
|
||||
if let Some(entry) = context.entries_by_file_id.get(file_id) {
|
||||
let project_id_string = project_id.get().to_string();
|
||||
let target_file_id_string = target_file_id.get().to_string();
|
||||
content_rows::upsert_content_update_check(
|
||||
&entry.id,
|
||||
context.instance.update_channel,
|
||||
Some(ContentProvider::CurseForge),
|
||||
Some(&project_id_string),
|
||||
Some(&target_file_id_string),
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
output.push(ContentUpdate::CurseForge {
|
||||
relative_path: instance_file.relative_path.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn curseforge_loader_type(loader: &str) -> Option<u32> {
|
||||
match loader {
|
||||
"forge" => Some(1),
|
||||
"fabric" => Some(4),
|
||||
"quilt" => Some(5),
|
||||
"neoforge" => Some(6),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn installed_update_channels(
|
||||
candidates: &[UpdateCandidate],
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
state: &State,
|
||||
) -> crate::Result<HashMap<String, ReleaseChannel>> {
|
||||
let version_ids = candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| Some(candidate.current_version_id.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let versions = CachedEntry::get_version_many(
|
||||
&version_ids,
|
||||
cache_behaviour,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
let channels_by_version_id = versions
|
||||
.into_iter()
|
||||
.map(|version| {
|
||||
(
|
||||
version.id,
|
||||
ReleaseChannel::from_version_type(&version.version_type),
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
Ok(candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| {
|
||||
channels_by_version_id
|
||||
.get(candidate.current_version_id.as_str())
|
||||
.copied()
|
||||
.map(|channel| (candidate.file.sha1.clone(), channel))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn effective_update_channel(
|
||||
preferred: ReleaseChannel,
|
||||
installed: Option<ReleaseChannel>,
|
||||
) -> ReleaseChannel {
|
||||
installed.map_or(preferred, |channel| preferred.least_stable(channel))
|
||||
}
|
||||
|
||||
fn update_cache_key(
|
||||
file: &InstanceFile,
|
||||
project_type: ProjectType,
|
||||
channel: ReleaseChannel,
|
||||
game_version: &str,
|
||||
loader: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"{}-{}-{}-{}",
|
||||
file.sha1,
|
||||
if project_type == ProjectType::Mod {
|
||||
loader.to_string()
|
||||
} else {
|
||||
project_type.get_loaders().join("+")
|
||||
},
|
||||
channel.key(),
|
||||
game_version
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,339 @@
|
||||
use crate::api::pack::import::{
|
||||
ImportLauncherType,
|
||||
direct_link::{direct_link_group, resolve_direct_link},
|
||||
};
|
||||
use crate::launcher::ExternalGameDirMode;
|
||||
use crate::state::instances::{
|
||||
ContentSet, ContentSetStatus, ContentSourceKind, Instance,
|
||||
InstanceLaunchOverrides, InstanceLink, LoaderComponent,
|
||||
adapters::sqlite::{content_rows, instance_rows, loader_component_rows},
|
||||
};
|
||||
use crate::state::{
|
||||
InstanceInstallStage, LauncherFeatureVersion, ReleaseChannel, State,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CreateDirectLinkInstance {
|
||||
/// Display name; defaults to the actual version JSON stem.
|
||||
pub name: Option<String>,
|
||||
/// Same launcher identity used by the existing import API.
|
||||
pub launcher_type: ImportLauncherType,
|
||||
/// Root selected for launcher scanning/import.
|
||||
pub base_path: PathBuf,
|
||||
/// Scanned launcher instance name/folder identity.
|
||||
pub instance_folder: String,
|
||||
/// Pre-resolved version directory, including compatible-mode selections.
|
||||
#[serde(default)]
|
||||
pub instance_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub game_dir_mode: Option<ExternalGameDirMode>,
|
||||
}
|
||||
|
||||
pub(crate) async fn create_direct_link_instance(
|
||||
input: CreateDirectLinkInstance,
|
||||
state: &State,
|
||||
) -> crate::Result<Instance> {
|
||||
let resolved = resolve_direct_link(
|
||||
input.launcher_type,
|
||||
input.base_path,
|
||||
input.instance_folder,
|
||||
input.instance_path,
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
launcher = resolved.launcher_key(),
|
||||
version_id = %resolved.version_id,
|
||||
version_json = %resolved.version_json.display(),
|
||||
"Creating directly associated instance"
|
||||
);
|
||||
|
||||
let name = input
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| resolved.version_id.clone());
|
||||
// Reserves a unique relative path for the instance record only; no
|
||||
// profile directory is ever created for directly associated instances.
|
||||
let (path, _full_path) =
|
||||
super::create_instance::resolve_instance_path(&name, None, state)
|
||||
.await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let instance_id = format!("local:{}", Uuid::new_v4());
|
||||
let content_set_id = format!("content-set:{}", Uuid::new_v4());
|
||||
let instance = Instance {
|
||||
id: instance_id.clone(),
|
||||
path: path.clone(),
|
||||
applied_content_set_id: Some(content_set_id.clone()),
|
||||
// Nothing to install: every file already sits in place inside the
|
||||
// linked `.minecraft`, so the instance is launchable immediately.
|
||||
install_stage: InstanceInstallStage::Installed,
|
||||
launcher_feature_version: LauncherFeatureVersion::MOST_RECENT,
|
||||
update_channel: ReleaseChannel::Release,
|
||||
name,
|
||||
icon_path: None,
|
||||
symlink_target: None,
|
||||
linked_launcher: Some(resolved.launcher_key().to_string()),
|
||||
linked_launcher_root: Some(
|
||||
resolved.launcher_root.to_string_lossy().to_string(),
|
||||
),
|
||||
linked_dot_minecraft: Some(
|
||||
resolved.dot_minecraft.to_string_lossy().to_string(),
|
||||
),
|
||||
linked_version_id: Some(resolved.version_id.clone()),
|
||||
linked_version_json_path: Some(
|
||||
resolved.version_json.to_string_lossy().to_string(),
|
||||
),
|
||||
linked_game_dir_mode: input
|
||||
.game_dir_mode
|
||||
.map(|mode| mode.key().to_string()),
|
||||
// Directly associated instances resolve their game directory from the
|
||||
// link metadata; the managed `game_dir_override` never applies.
|
||||
game_dir_override: None,
|
||||
created: now,
|
||||
modified: now,
|
||||
last_played: None,
|
||||
pinned_at: None,
|
||||
submitted_time_played: 0,
|
||||
recent_time_played: 0,
|
||||
};
|
||||
let content_set = ContentSet {
|
||||
id: content_set_id.clone(),
|
||||
instance_id: instance_id.clone(),
|
||||
name: "Default".to_string(),
|
||||
source_kind: ContentSourceKind::Local,
|
||||
status: ContentSetStatus::Available,
|
||||
game_version: resolved.game_version,
|
||||
protocol_version: None,
|
||||
loader: resolved.loader,
|
||||
// The loader is installed and managed by the external launcher; the
|
||||
// version parsed from the version JSON is not a reliable display value
|
||||
// for directly associated instances, so it is intentionally nulled.
|
||||
loader_version: None,
|
||||
revision: 0,
|
||||
created: now,
|
||||
modified: now,
|
||||
};
|
||||
let launch_overrides = InstanceLaunchOverrides::empty(instance_id.clone());
|
||||
|
||||
let mut tx = state.pool.begin().await?;
|
||||
instance_rows::insert_instance(&instance, &mut tx).await?;
|
||||
instance_rows::set_direct_link_fields(
|
||||
&instance.id,
|
||||
&instance_rows::DirectLinkFields {
|
||||
launcher: instance.linked_launcher.clone(),
|
||||
launcher_root: instance.linked_launcher_root.clone(),
|
||||
dot_minecraft: instance.linked_dot_minecraft.clone(),
|
||||
version_id: instance.linked_version_id.clone(),
|
||||
version_json_path: instance.linked_version_json_path.clone(),
|
||||
game_dir_mode: instance.linked_game_dir_mode.clone(),
|
||||
},
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
content_rows::insert_content_set(&content_set, &mut tx).await?;
|
||||
loader_component_rows::replace_loader_components(
|
||||
&instance_id,
|
||||
&LoaderComponent::from_legacy_projection(
|
||||
instance_id.clone(),
|
||||
resolved.loader,
|
||||
None,
|
||||
),
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
instance_rows::upsert_instance_link(
|
||||
&instance_id,
|
||||
&InstanceLink::Unmanaged,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
let groups = direct_link_group(&resolved.dot_minecraft)
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
instance_rows::replace_instance_groups(&instance_id, &groups, &mut tx)
|
||||
.await?;
|
||||
instance_rows::upsert_instance_launch_overrides(&launch_overrides, &mut tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Deliberately no config sync and no folder watcher: both would write
|
||||
// into or monitor folders outside of Axolotl's own directories.
|
||||
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::{DirectoryInfo, ModLoader};
|
||||
use serde_json::json;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_version(
|
||||
root: &Path,
|
||||
folder: &str,
|
||||
json_stem: &str,
|
||||
value: serde_json::Value,
|
||||
) -> std::io::Result<PathBuf> {
|
||||
let dir = root.join("versions").join(folder);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let json_path = dir.join(format!("{json_stem}.json"));
|
||||
std::fs::write(&json_path, serde_json::to_vec_pretty(&value).unwrap())?;
|
||||
Ok(json_path)
|
||||
}
|
||||
|
||||
async fn test_state_with_pool() -> crate::Result<(TempDir, Arc<State>)> {
|
||||
let temp = TempDir::new()?;
|
||||
let dirs = DirectoryInfo {
|
||||
settings_dir: temp.path().join("settings"),
|
||||
config_dir: temp.path().join("config"),
|
||||
app_identifier: "test".to_string(),
|
||||
};
|
||||
std::fs::create_dir_all(dirs.instances_dir())?;
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await?;
|
||||
sqlx::migrate!().run(&pool).await?;
|
||||
|
||||
Ok((temp, crate::state::test_state(dirs, pool).await?))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persists_resolved_direct_link_fields_without_creating_profile()
|
||||
-> crate::Result<()> {
|
||||
let (_temp, state) = test_state_with_pool().await?;
|
||||
let minecraft = TempDir::new()?;
|
||||
let json_path = write_version(
|
||||
minecraft.path(),
|
||||
"ui-folder",
|
||||
"1.20.1-forge",
|
||||
json!({
|
||||
"id": "1.20.1-forge",
|
||||
"inheritsFrom": "1.20.1",
|
||||
"mainClass": "forge.Main",
|
||||
"libraries": [
|
||||
{ "name": "net.minecraftforge:forge:1.20.1-47.4.0" }
|
||||
]
|
||||
}),
|
||||
)?;
|
||||
let source_before = std::fs::read(&json_path)?;
|
||||
|
||||
let instance = create_direct_link_instance(
|
||||
CreateDirectLinkInstance {
|
||||
name: Some("My Forge".to_string()),
|
||||
launcher_type: ImportLauncherType::Generic,
|
||||
base_path: minecraft.path().to_path_buf(),
|
||||
instance_folder: "versions/ui-folder".to_string(),
|
||||
instance_path: None,
|
||||
game_dir_mode: None,
|
||||
},
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(instance.name, "My Forge");
|
||||
assert_eq!(instance.linked_launcher.as_deref(), Some("generic"));
|
||||
assert_eq!(instance.linked_version_id.as_deref(), Some("1.20.1-forge"));
|
||||
assert_eq!(instance.install_stage, InstanceInstallStage::Installed);
|
||||
|
||||
let stored =
|
||||
instance_rows::get_instance_by_id(&instance.id, &state.pool)
|
||||
.await?
|
||||
.expect("instance row");
|
||||
let canonical_root = minecraft.path().canonicalize()?;
|
||||
let canonical_json = json_path.canonicalize()?;
|
||||
assert_eq!(stored.linked_launcher.as_deref(), Some("generic"));
|
||||
assert_eq!(
|
||||
stored.linked_launcher_root.as_deref(),
|
||||
Some(canonical_root.to_string_lossy().as_ref())
|
||||
);
|
||||
assert_eq!(
|
||||
stored.linked_dot_minecraft.as_deref(),
|
||||
Some(canonical_root.to_string_lossy().as_ref())
|
||||
);
|
||||
assert_eq!(
|
||||
stored.linked_version_json_path.as_deref(),
|
||||
Some(canonical_json.to_string_lossy().as_ref())
|
||||
);
|
||||
assert_eq!(stored.linked_version_id.as_deref(), Some("1.20.1-forge"));
|
||||
|
||||
let metadata = instance_rows::get_instance_metadata_by_id(
|
||||
&instance.id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.expect("metadata row");
|
||||
assert_eq!(metadata.applied_content_set.loader, ModLoader::Forge);
|
||||
assert_eq!(metadata.applied_content_set.game_version, "1.20.1");
|
||||
// Directly associated instances intentionally project no loader
|
||||
// version: the loader is installed and managed by the external
|
||||
// launcher, so the parsed value is not shown.
|
||||
assert_eq!(metadata.applied_content_set.loader_version, None);
|
||||
|
||||
assert!(
|
||||
!state
|
||||
.directories
|
||||
.instances_dir()
|
||||
.join(&instance.path)
|
||||
.exists()
|
||||
);
|
||||
assert_eq!(std::fs::read(json_path)?, source_before);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compatible_mode_fields_round_trip() -> crate::Result<()> {
|
||||
let (_temp, state) = test_state_with_pool().await?;
|
||||
let minecraft = TempDir::new()?;
|
||||
let json_path = write_version(
|
||||
minecraft.path(),
|
||||
"1.20.4",
|
||||
"1.20.4",
|
||||
json!({
|
||||
"id": "1.20.4",
|
||||
"mainClass": "net.minecraft.client.main.Main",
|
||||
"type": "release"
|
||||
}),
|
||||
)?;
|
||||
|
||||
let instance = create_direct_link_instance(
|
||||
CreateDirectLinkInstance {
|
||||
name: None,
|
||||
launcher_type: ImportLauncherType::PCL2CE,
|
||||
base_path: minecraft.path().to_path_buf(),
|
||||
instance_folder: "Friendly Name".to_string(),
|
||||
instance_path: Some(
|
||||
json_path
|
||||
.parent()
|
||||
.expect("version dir")
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
),
|
||||
game_dir_mode: None,
|
||||
},
|
||||
&state,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(instance.name, "1.20.4");
|
||||
assert_eq!(instance.linked_launcher.as_deref(), Some("pcl2_ce"));
|
||||
assert_eq!(instance.linked_version_id.as_deref(), Some("1.20.4"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
use crate::launcher::get_loader_version_from_profile;
|
||||
use crate::state::instances::{
|
||||
ContentSet, ContentSetStatus, ContentSourceKind, Instance,
|
||||
InstanceLaunchOverrides, InstanceLink, LoaderComponent,
|
||||
adapters::sqlite::{
|
||||
config_sync_rows, content_rows, instance_rows, loader_component_rows,
|
||||
},
|
||||
config_sync,
|
||||
};
|
||||
use crate::state::{
|
||||
InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel,
|
||||
State,
|
||||
};
|
||||
use crate::util::fetch::{self, write_cached_icon};
|
||||
use crate::util::io;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{info, trace};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CreateInstance {
|
||||
pub name: String,
|
||||
pub path: Option<String>,
|
||||
pub game_version: String,
|
||||
pub loader: ModLoader,
|
||||
pub loader_version: Option<String>,
|
||||
pub icon_path: Option<String>,
|
||||
pub link: InstanceLink,
|
||||
#[serde(default)]
|
||||
pub symlink_target: Option<String>,
|
||||
#[serde(default)]
|
||||
pub game_dir_override: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn create_instance(
|
||||
input: CreateInstance,
|
||||
state: &State,
|
||||
) -> crate::Result<Instance> {
|
||||
trace!("Creating new instance. {}", input.name);
|
||||
|
||||
let (path, full_path) =
|
||||
resolve_instance_path(&input.name, input.path.as_deref(), state)
|
||||
.await?;
|
||||
|
||||
if let Some(symlink_target) = &input.symlink_target {
|
||||
io::create_symlink(symlink_target, &full_path).await?;
|
||||
} else {
|
||||
io::create_dir_all(&full_path).await?;
|
||||
}
|
||||
|
||||
let result = async {
|
||||
if let Some(game_dir_override) = input.game_dir_override.as_deref() {
|
||||
// External game directories are created before the launcher
|
||||
// resolves them with `canonicalize` during installation.
|
||||
io::create_dir_all(&PathBuf::from(game_dir_override)).await?;
|
||||
if is_version_isolated_game_dir(Path::new(game_dir_override)) {
|
||||
// Keep the generic direct-link resolver on the isolated
|
||||
// version directory even while it is still empty.
|
||||
io::create_dir_all(
|
||||
PathBuf::from(game_dir_override).join("mods"),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Creating instance at path {}",
|
||||
&io::canonicalize(&full_path)?.display()
|
||||
);
|
||||
|
||||
let loader_version = if input.loader != ModLoader::Vanilla {
|
||||
get_loader_version_from_profile(
|
||||
&input.game_version,
|
||||
input.loader,
|
||||
input.loader_version.as_deref(),
|
||||
)
|
||||
.await?
|
||||
.map(|value| value.id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let icon_path =
|
||||
resolve_icon_path(input.icon_path.as_deref(), state).await?;
|
||||
let now = Utc::now();
|
||||
let instance_id = format!("local:{}", Uuid::new_v4());
|
||||
let content_set_id = format!("content-set:{}", Uuid::new_v4());
|
||||
let instance = Instance {
|
||||
id: instance_id.clone(),
|
||||
path: path.clone(),
|
||||
applied_content_set_id: Some(content_set_id.clone()),
|
||||
install_stage: InstanceInstallStage::NotInstalled,
|
||||
launcher_feature_version: LauncherFeatureVersion::MOST_RECENT,
|
||||
update_channel: ReleaseChannel::Release,
|
||||
name: input.name,
|
||||
icon_path,
|
||||
symlink_target: input.symlink_target,
|
||||
linked_launcher: None,
|
||||
linked_launcher_root: None,
|
||||
linked_dot_minecraft: None,
|
||||
linked_version_id: None,
|
||||
linked_version_json_path: None,
|
||||
linked_game_dir_mode: None,
|
||||
game_dir_override: input.game_dir_override,
|
||||
created: now,
|
||||
modified: now,
|
||||
last_played: None,
|
||||
pinned_at: None,
|
||||
submitted_time_played: 0,
|
||||
recent_time_played: 0,
|
||||
};
|
||||
let content_set = ContentSet {
|
||||
id: content_set_id,
|
||||
instance_id: instance_id.clone(),
|
||||
name: "Default".to_string(),
|
||||
source_kind: content_source_kind(&input.link),
|
||||
status: ContentSetStatus::Available,
|
||||
game_version: input.game_version,
|
||||
protocol_version: None,
|
||||
loader: input.loader,
|
||||
loader_version,
|
||||
revision: 0,
|
||||
created: now,
|
||||
modified: now,
|
||||
};
|
||||
let launch_overrides =
|
||||
InstanceLaunchOverrides::empty(instance_id.clone());
|
||||
let loader_components = LoaderComponent::from_legacy_projection(
|
||||
instance_id.clone(),
|
||||
input.loader,
|
||||
content_set.loader_version.clone(),
|
||||
);
|
||||
|
||||
let mut tx = state.pool.begin().await?;
|
||||
instance_rows::insert_instance(&instance, &mut tx).await?;
|
||||
content_rows::insert_content_set(&content_set, &mut tx).await?;
|
||||
loader_component_rows::replace_loader_components(
|
||||
&instance_id,
|
||||
&loader_components,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
instance_rows::upsert_instance_link(&instance_id, &input.link, &mut tx)
|
||||
.await?;
|
||||
instance_rows::replace_instance_groups(&instance_id, &[], &mut tx)
|
||||
.await?;
|
||||
instance_rows::upsert_instance_launch_overrides(
|
||||
&launch_overrides,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
config_sync_rows::upsert_config_updated_at(&instance_id, &mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
config_sync::mark_dirty(&instance_id);
|
||||
|
||||
crate::state::instances::watcher::watch_instance_folder(
|
||||
&instance.id,
|
||||
&instance.path,
|
||||
&state.directories.instance_game_dir(&instance),
|
||||
&state.file_watcher,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(instance)
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
let _ = io::remove_dir_all(&full_path).await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_instance_path(
|
||||
name: &str,
|
||||
path: Option<&str>,
|
||||
state: &State,
|
||||
) -> crate::Result<(String, std::path::PathBuf)> {
|
||||
let base_path = path
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| sanitize_instance_name(name));
|
||||
let mut path = base_path.clone();
|
||||
let mut full_path = state.directories.instances_dir().join(&path);
|
||||
|
||||
if path_available(&path, &full_path, state).await? {
|
||||
return Ok((path, full_path));
|
||||
}
|
||||
|
||||
let mut which = 1;
|
||||
loop {
|
||||
path = format!("{base_path} ({which})");
|
||||
full_path = state.directories.instances_dir().join(&path);
|
||||
|
||||
if path_available(&path, &full_path, state).await? {
|
||||
return Ok((path, full_path));
|
||||
}
|
||||
|
||||
which += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn is_version_isolated_game_dir(path: &Path) -> bool {
|
||||
path.parent()
|
||||
.and_then(Path::file_name)
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("versions"))
|
||||
}
|
||||
|
||||
async fn path_available(
|
||||
path: &str,
|
||||
full_path: &std::path::Path,
|
||||
state: &State,
|
||||
) -> crate::Result<bool> {
|
||||
if full_path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(instance_rows::get_instance_by_path(path, &state.pool)
|
||||
.await?
|
||||
.is_none())
|
||||
}
|
||||
|
||||
async fn resolve_icon_path(
|
||||
icon_path: Option<&str>,
|
||||
state: &State,
|
||||
) -> crate::Result<Option<String>> {
|
||||
let Some(icon) = icon_path else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let (bytes, file_name) = if icon.starts_with("https://")
|
||||
|| icon.starts_with("http://")
|
||||
{
|
||||
// Icon downloads are best-effort. CurseForge CDN icons often fail
|
||||
// through local system proxies; never block instance creation on that.
|
||||
let fetched = match fetch_icon_bytes(icon, state).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Failed to download instance icon from {icon}: {err}"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let name = icon.rsplit('/').next().unwrap_or("icon").to_string();
|
||||
(fetched, name)
|
||||
} else {
|
||||
let data =
|
||||
match io::read(state.directories.caches_dir().join(icon)).await {
|
||||
Ok(data) => data,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Failed to read instance icon from {icon}: {err}"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
(bytes::Bytes::from(data), icon.to_string())
|
||||
};
|
||||
|
||||
let file = write_cached_icon(
|
||||
&file_name,
|
||||
&state.directories.caches_dir(),
|
||||
bytes,
|
||||
&state.io_semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Some(file.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
async fn fetch_icon_bytes(
|
||||
icon_url: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<bytes::Bytes> {
|
||||
// Prefer a direct connection for CDN hosts that are commonly blocked or
|
||||
// broken through local HTTP proxies (e.g. media.forgecdn.net).
|
||||
if is_direct_cdn_icon_url(icon_url) {
|
||||
let permit = state.fetch_semaphore.0.acquire().await?;
|
||||
let response = DIRECT_ICON_CLIENT.get(icon_url).send().await?;
|
||||
drop(permit);
|
||||
if !response.status().is_success() {
|
||||
return Err(crate::ErrorKind::OtherError(format!(
|
||||
"Icon download failed with HTTP {}",
|
||||
response.status().as_u16()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
return Ok(response.bytes().await?);
|
||||
}
|
||||
|
||||
fetch::fetch(
|
||||
icon_url,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn is_direct_cdn_icon_url(url: &str) -> bool {
|
||||
let Ok(parsed) = reqwest::Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
|
||||
host == "forgecdn.net"
|
||||
|| host.ends_with(".forgecdn.net")
|
||||
|| host == "media.forgecdn.net"
|
||||
}
|
||||
|
||||
static DIRECT_ICON_CLIENT: std::sync::LazyLock<reqwest::Client> =
|
||||
std::sync::LazyLock::new(|| {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(15))
|
||||
.read_timeout(std::time::Duration::from_secs(30))
|
||||
.user_agent(crate::launcher_user_agent())
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("Direct icon client configuration should be valid")
|
||||
});
|
||||
|
||||
fn content_source_kind(link: &InstanceLink) -> ContentSourceKind {
|
||||
match link {
|
||||
InstanceLink::Unmanaged => ContentSourceKind::Local,
|
||||
InstanceLink::ModrinthModpack { .. } => {
|
||||
ContentSourceKind::ModrinthModpack
|
||||
}
|
||||
InstanceLink::CurseForgeModpack { .. } => ContentSourceKind::CurseForge,
|
||||
InstanceLink::ServerProject { .. }
|
||||
| InstanceLink::ServerProjectModpack { .. } => {
|
||||
ContentSourceKind::ServerProject
|
||||
}
|
||||
InstanceLink::ImportedModpack { .. } => {
|
||||
ContentSourceKind::ImportedModpack
|
||||
}
|
||||
InstanceLink::SharedInstance { .. } => {
|
||||
ContentSourceKind::SharedInstance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_instance_name(input: &str) -> String {
|
||||
input.trim().replace(
|
||||
['/', '\\', '?', '*', ':', '\'', '\"', '|', '<', '>', '!'],
|
||||
"_",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
use crate::state::instances::{
|
||||
ContentSourceKind, Instance, InstanceLaunchOverrides, InstanceLink,
|
||||
LoaderComponent,
|
||||
adapters::sqlite::{
|
||||
config_sync_rows, content_rows, instance_rows, loader_component_rows,
|
||||
},
|
||||
config_sync,
|
||||
};
|
||||
use crate::state::{
|
||||
Hooks, InstanceInstallStage, LauncherFeatureVersion, MemorySettings,
|
||||
ModLoader, ReleaseChannel, WindowSize,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct EditInstance {
|
||||
pub install_stage: Option<InstanceInstallStage>,
|
||||
pub launcher_feature_version: Option<LauncherFeatureVersion>,
|
||||
pub name: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub icon_path: Option<Option<String>>,
|
||||
pub update_channel: Option<ReleaseChannel>,
|
||||
pub groups: Option<Vec<String>>,
|
||||
pub link: Option<InstanceLink>,
|
||||
pub launch_overrides: Option<InstanceLaunchOverridesPatch>,
|
||||
pub content_set_patch: Option<AppliedContentSetPatch>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub last_played: Option<Option<DateTime<Utc>>>,
|
||||
pub submitted_time_played: Option<u64>,
|
||||
pub recent_time_played: Option<u64>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub symlink_target: Option<Option<String>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub game_dir_override: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct InstanceLaunchOverridesPatch {
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub java_path: Option<Option<String>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub extra_launch_args: Option<Option<Vec<String>>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub custom_env_vars: Option<Option<Vec<(String, String)>>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub memory: Option<Option<MemorySettings>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub force_fullscreen: Option<Option<bool>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub maximize_window: Option<Option<bool>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub game_resolution: Option<Option<WindowSize>>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub launch_preparation_timeout: Option<Option<u64>>,
|
||||
pub hooks: Option<Hooks>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct AppliedContentSetPatch {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_kind: Option<ContentSourceKind>,
|
||||
pub game_version: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub protocol_version: Option<Option<u32>>,
|
||||
pub loader: Option<ModLoader>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "serde_with::rust::double_option"
|
||||
)]
|
||||
pub loader_version: Option<Option<String>>,
|
||||
}
|
||||
|
||||
pub(crate) async fn edit_instance(
|
||||
instance_id: &str,
|
||||
patch: EditInstance,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Instance> {
|
||||
let mut instance = instance_rows::get_instance_by_id(instance_id, pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
let now = Utc::now();
|
||||
|
||||
apply_instance_patch(&mut instance, &patch, now);
|
||||
let loader_projection_changed =
|
||||
patch.content_set_patch.as_ref().is_some_and(|patch| {
|
||||
patch.loader.is_some() || patch.loader_version.is_some()
|
||||
});
|
||||
|
||||
let mut content_set = match patch.content_set_patch {
|
||||
Some(content_set_patch) => {
|
||||
let applied_content_set =
|
||||
content_rows::get_applied_content_set(&instance.id, pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Instance {} has no applied content set",
|
||||
instance.id
|
||||
))
|
||||
})?;
|
||||
Some(apply_content_set_patch(
|
||||
applied_content_set,
|
||||
content_set_patch,
|
||||
now,
|
||||
))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let mut launch_overrides = match patch.launch_overrides {
|
||||
Some(launch_patch) => {
|
||||
let current = instance_rows::get_instance_launch_overrides(
|
||||
&instance.id,
|
||||
pool,
|
||||
)
|
||||
.await?
|
||||
.unwrap_or_else(|| {
|
||||
InstanceLaunchOverrides::empty(instance.id.clone())
|
||||
});
|
||||
Some(apply_launch_overrides_patch(current, launch_patch))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
instance_rows::update_instance(&instance, &mut tx).await?;
|
||||
|
||||
if let Some(content_set) = content_set.as_mut() {
|
||||
content_rows::update_content_set(content_set, &mut tx).await?;
|
||||
if loader_projection_changed {
|
||||
let components = LoaderComponent::from_legacy_projection(
|
||||
instance.id.clone(),
|
||||
content_set.loader,
|
||||
content_set.loader_version.clone(),
|
||||
);
|
||||
loader_component_rows::replace_loader_components(
|
||||
&instance.id,
|
||||
&components,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(link) = &patch.link {
|
||||
instance_rows::upsert_instance_link(&instance.id, link, &mut tx)
|
||||
.await?;
|
||||
if matches!(link, InstanceLink::Unmanaged) {
|
||||
let content_set_id = content_set
|
||||
.as_ref()
|
||||
.map(|content_set| content_set.id.as_str())
|
||||
.or(instance.applied_content_set_id.as_deref());
|
||||
if let Some(content_set_id) = content_set_id {
|
||||
sqlx::query(
|
||||
"UPDATE instance_content_sets SET source_kind = 'local', modified = ? WHERE id = ?",
|
||||
)
|
||||
.bind(now.timestamp())
|
||||
.bind(content_set_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE instance_content_entries SET source_kind = 'local', ownership_kind = 'user_added', modified_at = ? WHERE content_set_id = ? AND ownership_kind = 'pack_managed'",
|
||||
)
|
||||
.bind(now.timestamp())
|
||||
.bind(content_set_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM instance_pack_members WHERE content_set_id = ?")
|
||||
.bind(content_set_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(groups) = &patch.groups {
|
||||
instance_rows::replace_instance_groups(&instance.id, groups, &mut tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(overrides) = launch_overrides.as_mut() {
|
||||
instance_rows::upsert_instance_launch_overrides(overrides, &mut tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
config_sync_rows::upsert_config_updated_at(&instance.id, &mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
config_sync::mark_dirty(&instance.id);
|
||||
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
pub(crate) async fn restore_instance_metadata(
|
||||
metadata: &crate::state::InstanceMetadata,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
let instance = metadata.instance.clone();
|
||||
let mut content_set = metadata.applied_content_set.clone();
|
||||
let mut launch_overrides = metadata.launch_overrides.clone();
|
||||
|
||||
instance_rows::update_instance(&instance, &mut tx).await?;
|
||||
content_rows::update_content_set(&mut content_set, &mut tx).await?;
|
||||
loader_component_rows::replace_loader_components(
|
||||
&instance.id,
|
||||
&metadata.loader_components,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
instance_rows::upsert_instance_link(&instance.id, &metadata.link, &mut tx)
|
||||
.await?;
|
||||
instance_rows::replace_instance_groups(
|
||||
&instance.id,
|
||||
&metadata.groups,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
instance_rows::upsert_instance_launch_overrides(
|
||||
&mut launch_overrides,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_instance_patch(
|
||||
instance: &mut Instance,
|
||||
patch: &EditInstance,
|
||||
now: DateTime<Utc>,
|
||||
) {
|
||||
if let Some(install_stage) = patch.install_stage {
|
||||
instance.install_stage = install_stage;
|
||||
}
|
||||
if let Some(launcher_feature_version) = patch.launcher_feature_version {
|
||||
instance.launcher_feature_version = launcher_feature_version;
|
||||
}
|
||||
if let Some(name) = &patch.name {
|
||||
instance.name = name.clone();
|
||||
}
|
||||
if let Some(icon_path) = &patch.icon_path {
|
||||
instance.icon_path = icon_path.clone();
|
||||
}
|
||||
if let Some(update_channel) = patch.update_channel {
|
||||
instance.update_channel = update_channel;
|
||||
}
|
||||
if let Some(last_played) = &patch.last_played {
|
||||
instance.last_played = *last_played;
|
||||
}
|
||||
if let Some(submitted_time_played) = patch.submitted_time_played {
|
||||
instance.submitted_time_played = submitted_time_played;
|
||||
}
|
||||
if let Some(recent_time_played) = patch.recent_time_played {
|
||||
instance.recent_time_played = recent_time_played;
|
||||
}
|
||||
if let Some(symlink_target) = &patch.symlink_target {
|
||||
instance.symlink_target = symlink_target.clone();
|
||||
}
|
||||
if let Some(game_dir_override) = &patch.game_dir_override {
|
||||
instance.game_dir_override = game_dir_override.clone();
|
||||
}
|
||||
|
||||
instance.modified = now;
|
||||
}
|
||||
|
||||
fn apply_content_set_patch(
|
||||
mut content_set: crate::state::instances::ContentSet,
|
||||
patch: AppliedContentSetPatch,
|
||||
now: DateTime<Utc>,
|
||||
) -> crate::state::instances::ContentSet {
|
||||
if let Some(game_version) = patch.game_version {
|
||||
content_set.game_version = game_version;
|
||||
}
|
||||
if let Some(source_kind) = patch.source_kind {
|
||||
content_set.source_kind = source_kind;
|
||||
}
|
||||
if let Some(protocol_version) = patch.protocol_version {
|
||||
content_set.protocol_version = protocol_version;
|
||||
}
|
||||
if let Some(loader) = patch.loader {
|
||||
content_set.loader = loader;
|
||||
}
|
||||
if let Some(loader_version) = patch.loader_version {
|
||||
content_set.loader_version = loader_version;
|
||||
}
|
||||
|
||||
content_set.modified = now;
|
||||
content_set
|
||||
}
|
||||
|
||||
fn apply_launch_overrides_patch(
|
||||
mut overrides: InstanceLaunchOverrides,
|
||||
patch: InstanceLaunchOverridesPatch,
|
||||
) -> InstanceLaunchOverrides {
|
||||
if let Some(java_path) = patch.java_path {
|
||||
overrides.java_path = java_path;
|
||||
}
|
||||
if let Some(extra_launch_args) = patch.extra_launch_args {
|
||||
overrides.extra_launch_args = extra_launch_args;
|
||||
}
|
||||
if let Some(custom_env_vars) = patch.custom_env_vars {
|
||||
overrides.custom_env_vars = custom_env_vars;
|
||||
}
|
||||
if let Some(memory) = patch.memory {
|
||||
overrides.memory = memory;
|
||||
}
|
||||
if let Some(force_fullscreen) = patch.force_fullscreen {
|
||||
overrides.force_fullscreen = force_fullscreen;
|
||||
}
|
||||
if let Some(maximize_window) = patch.maximize_window {
|
||||
overrides.maximize_window = maximize_window;
|
||||
}
|
||||
if let Some(game_resolution) = patch.game_resolution {
|
||||
overrides.game_resolution = game_resolution;
|
||||
}
|
||||
if let Some(timeout) = patch.launch_preparation_timeout {
|
||||
overrides.launch_preparation_timeout = timeout;
|
||||
}
|
||||
if let Some(hooks) = patch.hooks {
|
||||
overrides.hooks = hooks;
|
||||
}
|
||||
|
||||
overrides
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use crate::state::instances::{
|
||||
ContentSet, Instance, InstanceLaunchOverrides, InstanceLink,
|
||||
LoaderComponent,
|
||||
adapters::sqlite::{instance_rows, loader_component_rows},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceMetadata {
|
||||
pub instance: Instance,
|
||||
pub applied_content_set: ContentSet,
|
||||
pub link: InstanceLink,
|
||||
pub groups: Vec<String>,
|
||||
pub launch_overrides: InstanceLaunchOverrides,
|
||||
#[serde(default)]
|
||||
pub loader_components: Vec<LoaderComponent>,
|
||||
}
|
||||
|
||||
pub(crate) async fn get_instance(
|
||||
instance_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Option<InstanceMetadata>> {
|
||||
get_instance_metadata(instance_id, pool).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_instance_metadata(
|
||||
instance_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Option<InstanceMetadata>> {
|
||||
let Some(record) =
|
||||
instance_rows::get_instance_metadata_by_id(instance_id, pool).await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let loader_components =
|
||||
loader_component_rows::list_loader_components(instance_id, pool)
|
||||
.await?;
|
||||
Ok(Some(InstanceMetadata::from_record(
|
||||
record,
|
||||
loader_components,
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) async fn get_instances_metadata(
|
||||
instance_ids: &[&str],
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Vec<InstanceMetadata>> {
|
||||
let records =
|
||||
instance_rows::get_instance_metadata_many(instance_ids, pool).await?;
|
||||
let mut metadata = Vec::with_capacity(records.len());
|
||||
for record in records {
|
||||
let components = loader_component_rows::list_loader_components(
|
||||
&record.instance.id,
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
metadata.push(InstanceMetadata::from_record(record, components));
|
||||
}
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_instances(
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Vec<InstanceMetadata>> {
|
||||
let records = instance_rows::list_instance_metadata(pool).await?;
|
||||
let mut metadata = Vec::with_capacity(records.len());
|
||||
for record in records {
|
||||
let components = loader_component_rows::list_loader_components(
|
||||
&record.instance.id,
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
metadata.push(InstanceMetadata::from_record(record, components));
|
||||
}
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
impl InstanceMetadata {
|
||||
fn from_record(
|
||||
record: instance_rows::InstanceMetadataRecord,
|
||||
loader_components: Vec<LoaderComponent>,
|
||||
) -> Self {
|
||||
Self {
|
||||
instance: record.instance,
|
||||
applied_content_set: record.applied_content_set,
|
||||
link: record.link,
|
||||
groups: record.groups,
|
||||
launch_overrides: record.launch_overrides,
|
||||
loader_components,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
use crate::state::State;
|
||||
use crate::util::io;
|
||||
use crate::{ErrorKind, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Import a world save from a source path into an instance's saves directory.
|
||||
///
|
||||
/// The `source_path` can be either:
|
||||
/// - A directory containing a `level.dat` file (an existing world folder)
|
||||
/// - A ZIP archive containing a world save (`level.dat` at the archive root,
|
||||
/// or inside a single shared root folder such as `My World/level.dat`)
|
||||
///
|
||||
/// Returns the name of the imported world.
|
||||
pub async fn import_world_save(
|
||||
_state: &State,
|
||||
instance_id: &str,
|
||||
source_path: &Path,
|
||||
inner_base: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let resolved_source = if source_path.is_dir() {
|
||||
match inner_base {
|
||||
Some(base) => io::join_within_root(source_path, base)?,
|
||||
None => source_path.to_path_buf(),
|
||||
}
|
||||
} else {
|
||||
source_path.to_path_buf()
|
||||
};
|
||||
|
||||
// Resolve the instance's saves directory.
|
||||
let instance_path =
|
||||
crate::api::instance::get_full_path(&instance_id_str).await?;
|
||||
let saves_dir = instance_path.join("saves");
|
||||
|
||||
// Determine the world folder name and source type.
|
||||
let (world_name, source_is_zip) = if resolved_source.is_dir() {
|
||||
// Direct folder: use the folder name as the world name.
|
||||
let name = resolved_source
|
||||
.file_name()
|
||||
.ok_or_else(|| {
|
||||
ErrorKind::InputError(
|
||||
"Cannot determine world name from source path".to_string(),
|
||||
)
|
||||
})?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
(name, false)
|
||||
} else if resolved_source.is_file() {
|
||||
// Check if it's a ZIP archive by examining the file signature.
|
||||
let is_zip = is_zip_file(&resolved_source).await?;
|
||||
if is_zip {
|
||||
// ZIP file: use the file stem as the world name.
|
||||
let name = resolved_source
|
||||
.file_stem()
|
||||
.ok_or_else(|| {
|
||||
ErrorKind::InputError(
|
||||
"Cannot determine world name from ZIP file name"
|
||||
.to_string(),
|
||||
)
|
||||
})?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
(name, true)
|
||||
} else {
|
||||
return Err(ErrorKind::InputError(
|
||||
"Source file is not a valid ZIP archive or world folder"
|
||||
.to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
} else {
|
||||
return Err(ErrorKind::InputError(format!(
|
||||
"Source path does not exist: {}",
|
||||
source_path.display()
|
||||
))
|
||||
.into());
|
||||
};
|
||||
|
||||
// Check if the world already exists in the saves directory.
|
||||
let target_dir = saves_dir.join(&world_name);
|
||||
if target_dir.exists() {
|
||||
return Err(ErrorKind::InputError(format!(
|
||||
"World '{world_name}' already exists in this instance"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
// Create the saves directory if it doesn't exist.
|
||||
io::create_dir_all(&saves_dir).await?;
|
||||
|
||||
let import_result = async {
|
||||
if source_is_zip {
|
||||
// Extract ZIP archive to the target directory.
|
||||
extract_world_zip(&resolved_source, &target_dir).await?;
|
||||
// Deep-nesting fallback: the archive may wrap the world in backup
|
||||
// folders or even inside another ZIP. Hoist the first `level.dat`'s
|
||||
// folder to the target root, extracting nested archives as needed.
|
||||
let target_for_locator = target_dir.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
locate_world_root_sync(&target_for_locator, 0)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ErrorKind::InputError(format!(
|
||||
"World location task panicked: {e}"
|
||||
))
|
||||
})?
|
||||
.map_err(|e| {
|
||||
ErrorKind::InputError(format!(
|
||||
"Failed to locate world inside archive: {e}"
|
||||
))
|
||||
})?;
|
||||
} else {
|
||||
// Copy the folder recursively.
|
||||
io::copy_dir(&resolved_source, &target_dir).await?;
|
||||
}
|
||||
Ok::<(), crate::Error>(())
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = import_result {
|
||||
let _ = tokio::fs::remove_dir_all(&target_dir).await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
// Verify that the extracted/copied world has a level.dat file.
|
||||
if !target_dir.join("level.dat").exists() {
|
||||
// Clean up on failure.
|
||||
let _ = tokio::fs::remove_dir_all(&target_dir).await;
|
||||
return Err(ErrorKind::InputError(format!(
|
||||
"No level.dat found in the imported world save '{world_name}'"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
// Emit an instance synced event so the UI refreshes the worlds list.
|
||||
crate::event::emit::emit_instance(
|
||||
&instance_id_str,
|
||||
crate::event::InstancePayloadType::Synced,
|
||||
)
|
||||
.await?;
|
||||
crate::event::emit::emit_instance(
|
||||
&instance_id_str,
|
||||
crate::event::InstancePayloadType::WorldUpdated {
|
||||
world: world_name.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
"Imported world save '{world_name}' into instance {instance_id_str}"
|
||||
);
|
||||
|
||||
Ok(world_name)
|
||||
}
|
||||
|
||||
/// Check if a file is a ZIP archive by reading its magic bytes.
|
||||
async fn is_zip_file(path: &Path) -> Result<bool> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let mut file = tokio::fs::File::open(path)
|
||||
.await
|
||||
.map_err(|e| io::IOError::with_path(e, path))?;
|
||||
let mut magic = [0u8; 4];
|
||||
match file.read_exact(&mut magic).await {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
// File too small to be a ZIP
|
||||
return Ok(false);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(io::IOError::with_path(e, path).into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// ZIP magic bytes: PK\x03\x04
|
||||
Ok(magic == [0x50, 0x4B, 0x03, 0x04])
|
||||
}
|
||||
|
||||
/// Extract a ZIP archive containing a world save to the target directory.
|
||||
///
|
||||
/// Supports flat archives (`level.dat` at the root) and archives whose
|
||||
/// entries all share a single root folder (`My World/level.dat`); the shared
|
||||
/// root is stripped only when every entry lives inside it. Entry names are
|
||||
/// normalized (backslashes become `/`) and validated so `..`, absolute paths
|
||||
/// and drive letters can never write outside `target_dir`.
|
||||
async fn extract_world_zip(zip_path: &Path, target_dir: &Path) -> Result<()> {
|
||||
let zip_path = zip_path.to_path_buf();
|
||||
let target_dir = target_dir.to_path_buf();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
extract_world_zip_sync(&zip_path, &target_dir)
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
fn extract_world_zip_sync(zip_path: &Path, target_dir: &Path) -> Result<()> {
|
||||
let file = std::fs::File::open(zip_path)
|
||||
.map_err(|e| io::IOError::with_path(e, zip_path))?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|e| {
|
||||
ErrorKind::InputError(format!("Invalid ZIP archive: {e}"))
|
||||
})?;
|
||||
|
||||
// First pass: sanitize every entry name before writing anything. A world
|
||||
// archive with an escaping entry is rejected as a whole.
|
||||
let mut entries: Vec<(usize, PathBuf, bool)> = Vec::new();
|
||||
for i in 0..archive.len() {
|
||||
let entry = archive.by_index(i).map_err(|e| {
|
||||
ErrorKind::InputError(format!("Failed to read ZIP entry: {e}"))
|
||||
})?;
|
||||
|
||||
let raw_name = entry.name().to_string();
|
||||
if raw_name.starts_with("__MACOSX") {
|
||||
continue;
|
||||
}
|
||||
let is_dir =
|
||||
entry.is_dir() || raw_name.replace('\\', "/").ends_with('/');
|
||||
let safe_name = sanitize_entry_name(&raw_name).ok_or_else(|| {
|
||||
ErrorKind::InputError(format!(
|
||||
"World archive contains an unsafe ZIP entry: {raw_name}"
|
||||
))
|
||||
})?;
|
||||
if safe_name.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
entries.push((i, safe_name, is_dir));
|
||||
}
|
||||
|
||||
// Strip a shared root folder only when every file entry is nested below
|
||||
// it. Root-level directory entries don't block stripping, and a flat
|
||||
// archive (level.dat at the root) keeps its paths untouched.
|
||||
let all_nested = entries
|
||||
.iter()
|
||||
.filter(|(_, _, is_dir)| !is_dir)
|
||||
.all(|(_, path, _)| path.components().count() > 1);
|
||||
let root = common_root(&entries);
|
||||
let strip_root = all_nested && root.is_some();
|
||||
|
||||
for (index, safe_name, is_dir) in &entries {
|
||||
let relative = if strip_root {
|
||||
let mut components = safe_name.components();
|
||||
components.next();
|
||||
components.as_path().to_path_buf()
|
||||
} else {
|
||||
safe_name.clone()
|
||||
};
|
||||
let output_path = target_dir.join(relative);
|
||||
|
||||
if *is_dir {
|
||||
std::fs::create_dir_all(&output_path)
|
||||
.map_err(|e| io::IOError::with_path(e, &output_path))?;
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = output_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| io::IOError::with_path(e, parent))?;
|
||||
}
|
||||
|
||||
let mut entry = archive.by_index(*index).map_err(|e| {
|
||||
ErrorKind::InputError(format!("Failed to read ZIP entry: {e}"))
|
||||
})?;
|
||||
let mut output = std::fs::File::create(&output_path)
|
||||
.map_err(|e| io::IOError::with_path(e, &output_path))?;
|
||||
std::io::copy(&mut entry, &mut output)
|
||||
.map_err(|e| io::IOError::with_path(e, &output_path))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the first path component shared by every entry, if any.
|
||||
fn common_root(entries: &[(usize, PathBuf, bool)]) -> Option<&std::ffi::OsStr> {
|
||||
let mut root: Option<&std::ffi::OsStr> = None;
|
||||
for (_, path, _) in entries {
|
||||
let first = path.components().next()?.as_os_str();
|
||||
match root {
|
||||
None => root = Some(first),
|
||||
Some(existing) if existing != first => return None,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
/// Normalize a ZIP entry name into a safe relative path that stays inside the
|
||||
/// extraction directory. Returns `None` for absolute paths, drive letters or
|
||||
/// entries containing `..` (zip-slip protection).
|
||||
fn sanitize_entry_name(name: &str) -> Option<PathBuf> {
|
||||
// The ZIP spec mandates `/` separators, but tolerate backslashes from
|
||||
// Windows-authored archives.
|
||||
let normalized = name.replace('\\', "/");
|
||||
let path = Path::new(&normalized);
|
||||
if path.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
let mut safe = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
std::path::Component::Normal(part) => safe.push(part),
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir
|
||||
| std::path::Component::RootDir
|
||||
| std::path::Component::Prefix(_) => return None,
|
||||
}
|
||||
}
|
||||
Some(safe)
|
||||
}
|
||||
|
||||
/// Maximum number of nested archives unwrapped while locating a world.
|
||||
const MAX_WORLD_ZIP_NESTING_DEPTH: u32 = 5;
|
||||
|
||||
/// After extracting a world ZIP, restructure `target_dir` so `level.dat`
|
||||
/// ends up directly under it: search the extracted tree for the first
|
||||
/// `level.dat` and hoist its folder to the root; if only nested archives
|
||||
/// are present, extract the first one and repeat. Flat and single-root
|
||||
/// archives are already correct and return immediately.
|
||||
fn locate_world_root_sync(
|
||||
target_dir: &Path,
|
||||
depth: u32,
|
||||
) -> std::io::Result<()> {
|
||||
if target_dir.join("level.dat").is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
if depth >= MAX_WORLD_ZIP_NESTING_DEPTH {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(world_folder) = find_level_dat_folder(target_dir) {
|
||||
hoist_contents_sync(&world_folder, target_dir)?;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(nested_zip) = find_nested_archive(target_dir) {
|
||||
extract_world_zip_sync(&nested_zip, target_dir).map_err(|e| {
|
||||
std::io::Error::other(format!(
|
||||
"Failed to extract nested archive '{}': {e}",
|
||||
nested_zip.display()
|
||||
))
|
||||
})?;
|
||||
// The extracted archive itself is not part of the world.
|
||||
let _ = std::fs::remove_file(&nested_zip);
|
||||
return locate_world_root_sync(target_dir, depth + 1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Depth-first search for a directory containing `level.dat`, sorted by
|
||||
/// entry name for deterministic results.
|
||||
fn find_level_dat_folder(dir: &Path) -> Option<PathBuf> {
|
||||
let mut entries = std::fs::read_dir(dir)
|
||||
.ok()?
|
||||
.collect::<std::io::Result<Vec<_>>>()
|
||||
.ok()?;
|
||||
entries.sort_by_key(std::fs::DirEntry::file_name);
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if path.join("level.dat").is_file() {
|
||||
return Some(path);
|
||||
}
|
||||
if let Some(found) = find_level_dat_folder(&path) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Depth-first search for the first `.zip` / `.mrpack` file, sorted by entry
|
||||
/// name for deterministic results.
|
||||
fn find_nested_archive(dir: &Path) -> Option<PathBuf> {
|
||||
let mut entries = std::fs::read_dir(dir)
|
||||
.ok()?
|
||||
.collect::<std::io::Result<Vec<_>>>()
|
||||
.ok()?;
|
||||
entries.sort_by_key(std::fs::DirEntry::file_name);
|
||||
for entry in entries {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(found) = find_nested_archive(&path) {
|
||||
return Some(found);
|
||||
}
|
||||
} else if path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
|
||||
e.eq_ignore_ascii_case("zip") || e.eq_ignore_ascii_case("mrpack")
|
||||
}) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Move every child of `world_folder` into `target_dir`, then remove the
|
||||
/// now-empty wrapper folder.
|
||||
fn hoist_contents_sync(
|
||||
world_folder: &Path,
|
||||
target_dir: &Path,
|
||||
) -> std::io::Result<()> {
|
||||
let entries = std::fs::read_dir(world_folder)?;
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let dest = target_dir.join(entry.file_name());
|
||||
if dest.exists() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"Cannot hoist '{}': '{}' already exists",
|
||||
entry.path().display(),
|
||||
dest.display()
|
||||
)));
|
||||
}
|
||||
std::fs::rename(entry.path(), &dest)?;
|
||||
}
|
||||
let _ = std::fs::remove_dir(world_folder);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write as _;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn write_zip(entries: &[(&str, &[u8])], zip_path: &Path) {
|
||||
let file = std::fs::File::create(zip_path).expect("create zip");
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
for (name, bytes) in entries {
|
||||
zip.start_file(name, zip::write::FileOptions::<()>::default())
|
||||
.expect("start entry");
|
||||
zip.write_all(bytes).expect("write entry");
|
||||
}
|
||||
zip.finish().expect("finish zip");
|
||||
}
|
||||
|
||||
fn extract_to_temp(
|
||||
entries: &[(&str, &[u8])],
|
||||
) -> (tempfile::TempDir, tempfile::TempDir) {
|
||||
let dir = tempdir().expect("temp dir");
|
||||
let zip_path = dir.path().join("world.zip");
|
||||
write_zip(entries, &zip_path);
|
||||
|
||||
let out_dir = tempdir().expect("temp out dir");
|
||||
extract_world_zip_sync(&zip_path, out_dir.path()).expect("extract");
|
||||
(dir, out_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_zip_keeps_root_files() {
|
||||
let (_, out_dir) = extract_to_temp(&[
|
||||
("level.dat", b"flat"),
|
||||
("region/r.0.0.mca", b"mca"),
|
||||
]);
|
||||
|
||||
assert!(out_dir.path().join("level.dat").exists());
|
||||
assert!(out_dir.path().join("region/r.0.0.mca").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_root_zip_is_stripped() {
|
||||
let (_, out_dir) = extract_to_temp(&[
|
||||
("My World/level.dat", b"rooted"),
|
||||
("My World/region/r.0.0.mca", b"mca"),
|
||||
]);
|
||||
|
||||
assert!(out_dir.path().join("level.dat").exists());
|
||||
assert!(!out_dir.path().join("My World").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backslash_entries_are_normalized() {
|
||||
let (_, out_dir) = extract_to_temp(&[
|
||||
("My World\\level.dat", b"rooted"),
|
||||
("My World\\region\\r.0.0.mca", b"mca"),
|
||||
]);
|
||||
|
||||
assert!(out_dir.path().join("level.dat").exists());
|
||||
assert!(!out_dir.path().join("My World").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_entries_are_rejected() {
|
||||
let dir = tempdir().expect("temp dir");
|
||||
let zip_path = dir.path().join("world.zip");
|
||||
write_zip(
|
||||
&[("level.dat", b"world"), ("../../evil.txt", b"escape")],
|
||||
&zip_path,
|
||||
);
|
||||
let out_dir = tempdir().expect("temp out dir");
|
||||
|
||||
assert!(extract_world_zip_sync(&zip_path, out_dir.path()).is_err());
|
||||
|
||||
assert!(!dir.path().join("evil.txt").exists());
|
||||
assert!(!out_dir.path().join("level.dat").exists());
|
||||
assert_eq!(
|
||||
std::fs::read_dir(out_dir.path())
|
||||
.expect("read out dir")
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_entries_are_created() {
|
||||
let (_, out_dir) = extract_to_temp(&[
|
||||
("My World/", b""),
|
||||
("My World/level.dat", b"rooted"),
|
||||
]);
|
||||
|
||||
assert!(out_dir.path().join("level.dat").exists());
|
||||
assert!(!out_dir.path().join("My World").exists());
|
||||
}
|
||||
|
||||
fn extract_and_locate(entries: &[(&str, &[u8])]) -> tempfile::TempDir {
|
||||
let (_, out_dir) = extract_to_temp(entries);
|
||||
locate_world_root_sync(out_dir.path(), 0).expect("locate world");
|
||||
out_dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_backup_folders_are_hoisted() {
|
||||
let out_dir = extract_and_locate(&[
|
||||
("Backup/Worlds/My World/level.dat", b"rooted"),
|
||||
("Backup/Worlds/My World/region/r.0.0.mca", b"mca"),
|
||||
]);
|
||||
|
||||
assert!(
|
||||
out_dir.path().join("level.dat").exists(),
|
||||
"world nested under backup folders should be hoisted to the root"
|
||||
);
|
||||
assert!(out_dir.path().join("region/r.0.0.mca").exists());
|
||||
assert!(!out_dir.path().join("Backup").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_world_zip_is_extracted() {
|
||||
let mut inner = Vec::new();
|
||||
{
|
||||
let mut writer =
|
||||
zip::ZipWriter::new(std::io::Cursor::new(&mut inner));
|
||||
writer
|
||||
.start_file(
|
||||
"My World/level.dat",
|
||||
zip::write::FileOptions::<()>::default(),
|
||||
)
|
||||
.expect("start inner entry");
|
||||
writer.write_all(b"rooted").expect("write inner");
|
||||
writer
|
||||
.start_file(
|
||||
"My World/region/r.0.0.mca",
|
||||
zip::write::FileOptions::<()>::default(),
|
||||
)
|
||||
.expect("start inner region");
|
||||
writer.write_all(b"mca").expect("write inner region");
|
||||
writer.finish().expect("finish inner");
|
||||
}
|
||||
|
||||
let (_, out_dir) = extract_to_temp(&[
|
||||
("Backup/worlds/world1.zip", &inner),
|
||||
("Backup/worlds/world2.zip", &inner),
|
||||
]);
|
||||
locate_world_root_sync(out_dir.path(), 0).expect("locate world");
|
||||
|
||||
assert!(
|
||||
out_dir.path().join("level.dat").exists(),
|
||||
"nested world zip should be extracted and hoisted"
|
||||
);
|
||||
assert!(out_dir.path().join("region/r.0.0.mca").exists());
|
||||
assert!(
|
||||
!out_dir.path().join("Backup/worlds/world1.zip").exists(),
|
||||
"extracted nested archive should be removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_zip_chain_within_limit() {
|
||||
let mut world = Vec::new();
|
||||
{
|
||||
let mut writer =
|
||||
zip::ZipWriter::new(std::io::Cursor::new(&mut world));
|
||||
writer
|
||||
.start_file(
|
||||
"My World/level.dat",
|
||||
zip::write::FileOptions::<()>::default(),
|
||||
)
|
||||
.expect("start world entry");
|
||||
writer.write_all(b"rooted").expect("write world");
|
||||
writer.finish().expect("finish world");
|
||||
}
|
||||
let mut c = Vec::new();
|
||||
{
|
||||
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut c));
|
||||
writer
|
||||
.start_file(
|
||||
"world.zip",
|
||||
zip::write::FileOptions::<()>::default(),
|
||||
)
|
||||
.expect("start c entry");
|
||||
writer.write_all(&world).expect("write c");
|
||||
writer.finish().expect("finish c");
|
||||
}
|
||||
let mut b = Vec::new();
|
||||
{
|
||||
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut b));
|
||||
writer
|
||||
.start_file("c.zip", zip::write::FileOptions::<()>::default())
|
||||
.expect("start b entry");
|
||||
writer.write_all(&c).expect("write b");
|
||||
writer.finish().expect("finish b");
|
||||
}
|
||||
let mut a = Vec::new();
|
||||
{
|
||||
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut a));
|
||||
writer
|
||||
.start_file("b.zip", zip::write::FileOptions::<()>::default())
|
||||
.expect("start a entry");
|
||||
writer.write_all(&b).expect("write a");
|
||||
writer.finish().expect("finish a");
|
||||
}
|
||||
|
||||
let (_, out_dir) = extract_to_temp(&[("Backup/a.zip", &a)]);
|
||||
locate_world_root_sync(out_dir.path(), 0).expect("locate world");
|
||||
|
||||
assert!(
|
||||
out_dir.path().join("level.dat").exists(),
|
||||
"nested zip chain should unwrap to the world"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,720 @@
|
||||
use crate::state::InstanceInstallStage;
|
||||
use crate::state::instances::{
|
||||
InstanceLaunchContext, LoaderComponent,
|
||||
adapters::sqlite::{
|
||||
config_sync_rows, instance_rows, loader_component_rows,
|
||||
},
|
||||
config_sync, playtime_to_storage,
|
||||
};
|
||||
use chrono::{DateTime, Local, NaiveDate, TimeZone, Utc};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::state::instances::{DailyPlaytime, DailyPlaytimeEntry};
|
||||
|
||||
pub(crate) async fn get_instance_launch_context(
|
||||
instance_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Option<InstanceLaunchContext>> {
|
||||
instance_rows::get_instance_launch_context(instance_id, pool).await
|
||||
}
|
||||
|
||||
pub(crate) async fn replace_instance_loader_components(
|
||||
instance_id: &str,
|
||||
components: &[LoaderComponent],
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let (loader, loader_version) =
|
||||
crate::state::project_loader_components(components)?;
|
||||
let mut tx = pool.begin().await?;
|
||||
loader_component_rows::replace_loader_components(
|
||||
instance_id,
|
||||
components,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE instance_content_sets
|
||||
SET loader = ?, loader_version = ?, modified = ?
|
||||
WHERE id = (
|
||||
SELECT applied_content_set_id FROM instances WHERE id = ?
|
||||
)",
|
||||
)
|
||||
.bind(loader.as_str())
|
||||
.bind(loader_version)
|
||||
.bind(Utc::now().timestamp())
|
||||
.bind(instance_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
config_sync_rows::upsert_config_updated_at(instance_id, &mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
config_sync::mark_dirty(instance_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_instance_install_stage(
|
||||
instance_id: &str,
|
||||
install_stage: InstanceInstallStage,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let install_stage = install_stage.as_str();
|
||||
let modified = Utc::now().timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instances
|
||||
SET install_stage = ?, modified = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
install_stage,
|
||||
modified,
|
||||
instance_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_applied_content_set_loader_version(
|
||||
instance_id: &str,
|
||||
loader_version: Option<&str>,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let modified = Utc::now().timestamp();
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instance_content_sets
|
||||
SET loader_version = ?, modified = ?
|
||||
WHERE id = (
|
||||
SELECT applied_content_set_id
|
||||
FROM instances
|
||||
WHERE id = ?
|
||||
)
|
||||
",
|
||||
loader_version,
|
||||
modified,
|
||||
instance_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
config_sync_rows::upsert_config_updated_at(instance_id, &mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
config_sync::mark_dirty(instance_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_applied_content_set_protocol_version(
|
||||
instance_id: &str,
|
||||
protocol_version: Option<u32>,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let protocol_version = protocol_version.map(i64::from);
|
||||
let modified = Utc::now().timestamp();
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instance_content_sets
|
||||
SET protocol_version = ?, modified = ?
|
||||
WHERE id = (
|
||||
SELECT applied_content_set_id
|
||||
FROM instances
|
||||
WHERE id = ?
|
||||
)
|
||||
",
|
||||
protocol_version,
|
||||
modified,
|
||||
instance_id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
config_sync_rows::upsert_config_updated_at(instance_id, &mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
config_sync::mark_dirty(instance_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_instance_last_played(
|
||||
instance_id: &str,
|
||||
last_played: DateTime<Utc>,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let last_played = last_played.timestamp();
|
||||
let modified = Utc::now().timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instances
|
||||
SET last_played = ?, modified = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
last_played,
|
||||
modified,
|
||||
instance_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_instance_pinned(
|
||||
instance_id: &str,
|
||||
pinned: bool,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let pinned_at = pinned.then(|| Utc::now().timestamp());
|
||||
let modified = Utc::now().timestamp();
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
UPDATE instances
|
||||
SET pinned_at = ?, modified = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
pinned_at,
|
||||
modified,
|
||||
instance_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Unknown instance".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn record_instance_play_session(
|
||||
instance_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let now = Utc::now();
|
||||
let instance_name = get_instance_name(instance_id, pool).await?;
|
||||
upsert_daily_playtime(
|
||||
instance_id,
|
||||
&instance_name,
|
||||
now.with_timezone(&Local).date_naive(),
|
||||
0,
|
||||
1,
|
||||
pool,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn record_instance_daily_playtime(
|
||||
instance_id: &str,
|
||||
started_at: DateTime<Utc>,
|
||||
ended_at: DateTime<Utc>,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
if ended_at <= started_at {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let instance_name = get_instance_name(instance_id, pool).await?;
|
||||
for (played_on, elapsed) in
|
||||
split_daily_playtime(started_at, ended_at, &Local)?
|
||||
{
|
||||
upsert_daily_playtime(
|
||||
instance_id,
|
||||
&instance_name,
|
||||
played_on,
|
||||
elapsed,
|
||||
0,
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn split_daily_playtime<Tz: TimeZone>(
|
||||
started_at: DateTime<Utc>,
|
||||
ended_at: DateTime<Utc>,
|
||||
time_zone: &Tz,
|
||||
) -> crate::Result<Vec<(NaiveDate, u64)>> {
|
||||
if ended_at <= started_at {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut segment_start = started_at;
|
||||
let mut segments = Vec::new();
|
||||
while segment_start < ended_at {
|
||||
let local_start = segment_start.with_timezone(time_zone);
|
||||
let played_on = local_start.date_naive();
|
||||
let next_day = played_on.succ_opt().ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Unable to determine the next local day".to_string(),
|
||||
)
|
||||
})?;
|
||||
let next_midnight = next_day.and_hms_opt(0, 0, 0).ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Unable to determine the next local midnight".to_string(),
|
||||
)
|
||||
})?;
|
||||
let next_boundary = time_zone
|
||||
.from_local_datetime(&next_midnight)
|
||||
.earliest()
|
||||
.or_else(|| time_zone.from_local_datetime(&next_midnight).latest())
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Unable to resolve the next local day boundary".to_string(),
|
||||
)
|
||||
})?
|
||||
.with_timezone(&Utc);
|
||||
let segment_end = next_boundary.min(ended_at);
|
||||
let elapsed = (segment_end - segment_start).num_seconds();
|
||||
if elapsed <= 0 {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Unable to advance local playtime boundary".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
segments.push((played_on, elapsed as u64));
|
||||
segment_start = segment_end;
|
||||
}
|
||||
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
pub(crate) async fn get_daily_playtime(
|
||||
start_date: NaiveDate,
|
||||
end_date: NaiveDate,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Vec<DailyPlaytime>> {
|
||||
if start_date > end_date {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Start date must not be after end date".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let start_date = start_date.format("%Y-%m-%d").to_string();
|
||||
let end_date = end_date.format("%Y-%m-%d").to_string();
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
daily.played_on AS "date!: String",
|
||||
SUM(daily.played_seconds) AS "played_seconds!: i64",
|
||||
SUM(daily.session_count) AS "session_count!: i64",
|
||||
(
|
||||
SELECT candidate.instance_name
|
||||
FROM instance_daily_playtime candidate
|
||||
WHERE candidate.played_on = daily.played_on
|
||||
AND candidate.played_seconds > 0
|
||||
ORDER BY candidate.played_seconds DESC, candidate.instance_name ASC
|
||||
LIMIT 1
|
||||
) AS "top_instance_name?: String"
|
||||
FROM instance_daily_playtime daily
|
||||
WHERE daily.played_on BETWEEN ? AND ?
|
||||
GROUP BY daily.played_on
|
||||
ORDER BY daily.played_on
|
||||
"#,
|
||||
start_date,
|
||||
end_date,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(DailyPlaytime {
|
||||
date: row.date,
|
||||
played_seconds: u64::try_from(row.played_seconds).map_err(
|
||||
|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Invalid daily playtime value".to_string(),
|
||||
)
|
||||
},
|
||||
)?,
|
||||
session_count: u64::try_from(row.session_count).map_err(
|
||||
|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Invalid daily session count".to_string(),
|
||||
)
|
||||
},
|
||||
)?,
|
||||
top_instance_name: row.top_instance_name,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn get_daily_playtime_details(
|
||||
date: NaiveDate,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Vec<DailyPlaytimeEntry>> {
|
||||
let date = date.format("%Y-%m-%d").to_string();
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
daily.instance_id AS "instance_id!: String",
|
||||
daily.instance_name AS "instance_name!: String",
|
||||
daily.played_seconds AS "played_seconds!: i64",
|
||||
daily.session_count AS "session_count!: i64"
|
||||
FROM instance_daily_playtime daily
|
||||
WHERE daily.played_on = ?
|
||||
AND daily.played_seconds > 0
|
||||
ORDER BY daily.played_seconds DESC, daily.instance_name ASC
|
||||
"#,
|
||||
date,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(DailyPlaytimeEntry {
|
||||
instance_id: row.instance_id,
|
||||
instance_name: row.instance_name,
|
||||
played_seconds: u64::try_from(row.played_seconds).map_err(
|
||||
|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Invalid daily playtime value".to_string(),
|
||||
)
|
||||
},
|
||||
)?,
|
||||
session_count: u64::try_from(row.session_count).map_err(
|
||||
|_| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Invalid daily session count".to_string(),
|
||||
)
|
||||
},
|
||||
)?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_instance_name(
|
||||
instance_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<String> {
|
||||
sqlx::query_scalar!(
|
||||
"
|
||||
SELECT name
|
||||
FROM instances
|
||||
WHERE id = ?
|
||||
",
|
||||
instance_id,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string()).into()
|
||||
})
|
||||
}
|
||||
|
||||
async fn upsert_daily_playtime(
|
||||
instance_id: &str,
|
||||
instance_name: &str,
|
||||
played_on: NaiveDate,
|
||||
played_seconds: u64,
|
||||
session_count: u64,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let played_seconds = playtime_to_storage(played_seconds, "played_seconds")?;
|
||||
let session_count = playtime_to_storage(session_count, "session_count")?;
|
||||
let played_on = played_on.format("%Y-%m-%d").to_string();
|
||||
let max_played_seconds_before_increment = i64::MAX - played_seconds;
|
||||
let max_session_count_before_increment = i64::MAX - session_count;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO instance_daily_playtime (
|
||||
played_on,
|
||||
instance_id,
|
||||
instance_name,
|
||||
played_seconds,
|
||||
session_count
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(played_on, instance_id) DO UPDATE SET
|
||||
instance_name = excluded.instance_name,
|
||||
played_seconds = CASE
|
||||
WHEN instance_daily_playtime.played_seconds > ? THEN ?
|
||||
ELSE instance_daily_playtime.played_seconds + excluded.played_seconds
|
||||
END,
|
||||
session_count = CASE
|
||||
WHEN instance_daily_playtime.session_count > ? THEN ?
|
||||
ELSE instance_daily_playtime.session_count + excluded.session_count
|
||||
END
|
||||
",
|
||||
played_on,
|
||||
instance_id,
|
||||
instance_name,
|
||||
played_seconds,
|
||||
session_count,
|
||||
max_played_seconds_before_increment,
|
||||
i64::MAX,
|
||||
max_session_count_before_increment,
|
||||
i64::MAX,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn add_instance_recent_playtime(
|
||||
instance_id: &str,
|
||||
seconds: u64,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
if seconds == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let seconds = playtime_to_storage(seconds, "recent_time_played")?;
|
||||
let max_playtime = i64::MAX;
|
||||
let max_playtime_before_increment = max_playtime - seconds;
|
||||
let modified = Utc::now().timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instances
|
||||
SET
|
||||
recent_time_played = CASE
|
||||
WHEN recent_time_played < 0 THEN ?
|
||||
WHEN recent_time_played > ? THEN ?
|
||||
ELSE recent_time_played + ?
|
||||
END,
|
||||
modified = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
seconds,
|
||||
max_playtime_before_increment,
|
||||
max_playtime,
|
||||
seconds,
|
||||
modified,
|
||||
instance_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_instance_playtime_submitted(
|
||||
instance_id: &str,
|
||||
recent_time_played: u64,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
if recent_time_played == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let recent_time_played =
|
||||
playtime_to_storage(recent_time_played, "recent_time_played")?;
|
||||
let max_playtime = i64::MAX;
|
||||
let max_playtime_before_increment = max_playtime - recent_time_played;
|
||||
let modified = Utc::now().timestamp();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instances
|
||||
SET
|
||||
submitted_time_played = CASE
|
||||
WHEN submitted_time_played < 0 THEN ?
|
||||
WHEN submitted_time_played > ? THEN ?
|
||||
ELSE submitted_time_played + ?
|
||||
END,
|
||||
recent_time_played = 0,
|
||||
modified = ?
|
||||
WHERE id = ?
|
||||
",
|
||||
recent_time_played,
|
||||
max_playtime_before_increment,
|
||||
max_playtime,
|
||||
recent_time_played,
|
||||
modified,
|
||||
instance_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{TimeZone, Utc};
|
||||
use chrono_tz::America::New_York;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
|
||||
use super::{
|
||||
get_daily_playtime, set_instance_pinned, split_daily_playtime,
|
||||
upsert_daily_playtime,
|
||||
};
|
||||
|
||||
fn to_utc(
|
||||
year: i32,
|
||||
month: u32,
|
||||
day: u32,
|
||||
hour: u32,
|
||||
minute: u32,
|
||||
) -> chrono::DateTime<Utc> {
|
||||
New_York
|
||||
.with_ymd_and_hms(year, month, day, hour, minute, 0)
|
||||
.single()
|
||||
.expect("valid New York local time")
|
||||
.with_timezone(&Utc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_playtime_across_local_midnight() {
|
||||
let segments = split_daily_playtime(
|
||||
to_utc(2026, 2, 7, 23, 30),
|
||||
to_utc(2026, 2, 8, 1, 30),
|
||||
&New_York,
|
||||
)
|
||||
.expect("playtime should split");
|
||||
assert_eq!(
|
||||
segments,
|
||||
vec![
|
||||
("2026-02-07".parse().unwrap(), 30 * 60),
|
||||
("2026-02-08".parse().unwrap(), 90 * 60),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures_daylight_saving_transitions_by_elapsed_time() {
|
||||
let spring = split_daily_playtime(
|
||||
to_utc(2026, 3, 8, 0, 30),
|
||||
to_utc(2026, 3, 8, 3, 30),
|
||||
&New_York,
|
||||
)
|
||||
.expect("spring daylight saving playtime should split");
|
||||
let autumn = split_daily_playtime(
|
||||
to_utc(2026, 11, 1, 0, 30),
|
||||
to_utc(2026, 11, 1, 2, 30),
|
||||
&New_York,
|
||||
)
|
||||
.expect("autumn daylight saving playtime should split");
|
||||
|
||||
assert_eq!(spring[0].1, 2 * 60 * 60);
|
||||
assert_eq!(autumn[0].1, 3 * 60 * 60);
|
||||
}
|
||||
|
||||
async fn test_pool() -> sqlx::SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("in-memory SQLite pool");
|
||||
sqlx::query(
|
||||
"CREATE TABLE instances (id TEXT PRIMARY KEY, name TEXT NOT NULL, pinned_at INTEGER NULL, modified INTEGER NOT NULL)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("instances table");
|
||||
sqlx::query(
|
||||
"CREATE TABLE instance_daily_playtime (played_on TEXT NOT NULL, instance_id TEXT NOT NULL, instance_name TEXT NOT NULL, played_seconds INTEGER NOT NULL DEFAULT 0, session_count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (played_on, instance_id))",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("daily playtime table");
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aggregates_sessions_and_selects_the_top_instance() {
|
||||
let pool = test_pool().await;
|
||||
upsert_daily_playtime(
|
||||
"first",
|
||||
"First instance",
|
||||
"2026-07-25".parse().unwrap(),
|
||||
60,
|
||||
1,
|
||||
&pool,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
upsert_daily_playtime(
|
||||
"second",
|
||||
"Second instance",
|
||||
"2026-07-25".parse().unwrap(),
|
||||
240,
|
||||
2,
|
||||
&pool,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
upsert_daily_playtime(
|
||||
"first",
|
||||
"First instance",
|
||||
"2026-07-25".parse().unwrap(),
|
||||
300,
|
||||
1,
|
||||
&pool,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let summary = get_daily_playtime(
|
||||
"2026-07-25".parse().unwrap(),
|
||||
"2026-07-25".parse().unwrap(),
|
||||
&pool,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(summary[0].played_seconds, 600);
|
||||
assert_eq!(summary[0].session_count, 4);
|
||||
assert_eq!(
|
||||
summary[0].top_instance_name.as_deref(),
|
||||
Some("First instance")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persists_pinned_state_without_erasing_playtime_history() {
|
||||
let pool = test_pool().await;
|
||||
sqlx::query("INSERT INTO instances (id, name, pinned_at, modified) VALUES ('instance', 'Instance', NULL, 0)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
upsert_daily_playtime(
|
||||
"instance",
|
||||
"Instance",
|
||||
"2026-07-25".parse().unwrap(),
|
||||
120,
|
||||
1,
|
||||
&pool,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
set_instance_pinned("instance", true, &pool).await.unwrap();
|
||||
let pinned_at: Option<i64> = sqlx::query_scalar(
|
||||
"SELECT pinned_at FROM instances WHERE id = 'instance'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(pinned_at.is_some());
|
||||
set_instance_pinned("instance", false, &pool).await.unwrap();
|
||||
sqlx::query("DELETE FROM instances WHERE id = 'instance'")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rows: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM instance_daily_playtime WHERE instance_id = 'instance'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, 1);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,75 @@
|
||||
mod create_instance;
|
||||
pub use self::create_instance::CreateInstance;
|
||||
pub(crate) use self::create_instance::create_instance;
|
||||
|
||||
mod create_direct_link_instance;
|
||||
pub use self::create_direct_link_instance::CreateDirectLinkInstance;
|
||||
pub(crate) use self::create_direct_link_instance::create_direct_link_instance;
|
||||
mod sync_direct_link_instances;
|
||||
pub(crate) use self::sync_direct_link_instances::sync_direct_link_instances;
|
||||
pub use self::sync_direct_link_instances::{
|
||||
DirectLinkSyncReport, ExternalMinecraftRoot,
|
||||
};
|
||||
|
||||
mod edit_instance;
|
||||
pub use self::edit_instance::{
|
||||
AppliedContentSetPatch, EditInstance, InstanceLaunchOverridesPatch,
|
||||
};
|
||||
pub(crate) use self::edit_instance::{
|
||||
edit_instance, restore_instance_metadata,
|
||||
};
|
||||
|
||||
mod get_instance;
|
||||
pub use self::get_instance::InstanceMetadata;
|
||||
pub(crate) use self::get_instance::{
|
||||
get_instance, get_instance_metadata, get_instances_metadata, list_instances,
|
||||
};
|
||||
|
||||
mod list_content;
|
||||
pub(crate) use self::list_content::{
|
||||
dependencies_to_content_items, get_content_projects,
|
||||
get_installed_project_ids_for_instance, get_instance_install_candidates,
|
||||
get_linked_modpack_info, list_content, list_content_by_paths,
|
||||
list_content_sets, list_linked_modpack_content,
|
||||
};
|
||||
|
||||
mod content_snapshot;
|
||||
pub(crate) use self::content_snapshot::{
|
||||
get_content_snapshot, reconcile_curseforge_members,
|
||||
};
|
||||
|
||||
mod remove_instance;
|
||||
pub(crate) use self::remove_instance::*;
|
||||
|
||||
mod refresh_instances;
|
||||
pub(crate) use self::refresh_instances::*;
|
||||
|
||||
mod sync_content_files;
|
||||
pub(crate) use self::sync_content_files::{
|
||||
instance_content_root, sync_content_files,
|
||||
};
|
||||
|
||||
mod launch_context;
|
||||
pub(crate) use self::launch_context::*;
|
||||
|
||||
mod apply_content_install;
|
||||
pub(crate) use self::apply_content_install::*;
|
||||
|
||||
mod check_content_updates;
|
||||
|
||||
mod instance_upgrade;
|
||||
pub(crate) use self::instance_upgrade::{
|
||||
ReadOnlyUpgradeSource, UpgradePlanRuntimeValidation,
|
||||
create_instance_upgrade_plan_with_source,
|
||||
recompute_instance_upgrade_plan_from_source,
|
||||
scan_instance_upgrade_source_files, validate_instance_upgrade_plan_source,
|
||||
};
|
||||
|
||||
mod post_upgrade_notice;
|
||||
pub(crate) use self::post_upgrade_notice::*;
|
||||
|
||||
mod apply_content_update;
|
||||
pub(crate) use self::apply_content_update::*;
|
||||
|
||||
mod import_world_save;
|
||||
pub(crate) use self::import_world_save::import_world_save;
|
||||
@@ -0,0 +1,526 @@
|
||||
use sqlx::{Row, SqliteConnection, SqlitePool};
|
||||
|
||||
use crate::state::{InstancePostUpgradeNotice, InstancePostUpgradeWarning};
|
||||
|
||||
pub(crate) async fn get_instance_post_upgrade_notice(
|
||||
instance_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<Option<InstancePostUpgradeNotice>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT upgrade_job_id, target_game_version, consecutive_clean_launches, warnings_json FROM instance_post_upgrade_notices WHERE instance_id = ?",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
row.map(|row| {
|
||||
let warnings_json: String = row.try_get("warnings_json")?;
|
||||
Ok(InstancePostUpgradeNotice {
|
||||
instance_id: instance_id.to_string(),
|
||||
upgrade_job_id: row.try_get("upgrade_job_id")?,
|
||||
target_game_version: row.try_get("target_game_version")?,
|
||||
consecutive_clean_launches: row
|
||||
.try_get::<i64, _>("consecutive_clean_launches")?
|
||||
.clamp(0, u8::MAX as i64)
|
||||
as u8,
|
||||
warnings: serde_json::from_str(&warnings_json)?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(crate) async fn replace_instance_post_upgrade_notice(
|
||||
notice: &InstancePostUpgradeNotice,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let mut connection = pool.acquire().await?;
|
||||
replace_instance_post_upgrade_notice_on_connection(notice, &mut connection)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn replace_instance_post_upgrade_notice_on_connection(
|
||||
notice: &InstancePostUpgradeNotice,
|
||||
connection: &mut SqliteConnection,
|
||||
) -> crate::Result<()> {
|
||||
if notice.warnings.is_empty() {
|
||||
sqlx::query(
|
||||
"DELETE FROM instance_post_upgrade_notices WHERE instance_id = ?",
|
||||
)
|
||||
.bind(¬ice.instance_id)
|
||||
.execute(&mut *connection)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO instance_post_upgrade_notices (instance_id, upgrade_job_id, target_game_version, consecutive_clean_launches, warnings_json) VALUES (?, ?, ?, ?, ?) ON CONFLICT(instance_id) DO UPDATE SET upgrade_job_id = excluded.upgrade_job_id, target_game_version = excluded.target_game_version, consecutive_clean_launches = excluded.consecutive_clean_launches, warnings_json = excluded.warnings_json, modified = CURRENT_TIMESTAMP",
|
||||
)
|
||||
.bind(¬ice.instance_id)
|
||||
.bind(¬ice.upgrade_job_id)
|
||||
.bind(¬ice.target_game_version)
|
||||
.bind(i64::from(notice.consecutive_clean_launches))
|
||||
.bind(serde_json::to_string(¬ice.warnings)?)
|
||||
.execute(&mut *connection)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn dismiss_instance_post_upgrade_notice(
|
||||
instance_id: &str,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
sqlx::query(
|
||||
"DELETE FROM instance_post_upgrade_notices WHERE instance_id = ?",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn next_post_upgrade_notice_after_launch(
|
||||
mut notice: InstancePostUpgradeNotice,
|
||||
clean: bool,
|
||||
) -> Option<InstancePostUpgradeNotice> {
|
||||
if clean {
|
||||
notice.consecutive_clean_launches =
|
||||
notice.consecutive_clean_launches.saturating_add(1);
|
||||
(notice.consecutive_clean_launches < 2).then_some(notice)
|
||||
} else {
|
||||
notice.consecutive_clean_launches = 0;
|
||||
Some(notice)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn record_instance_post_upgrade_launch(
|
||||
instance_id: &str,
|
||||
clean: bool,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let Some(notice) =
|
||||
get_instance_post_upgrade_notice(instance_id, pool).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
match next_post_upgrade_notice_after_launch(notice, clean) {
|
||||
Some(notice) => {
|
||||
replace_instance_post_upgrade_notice(¬ice, pool).await
|
||||
}
|
||||
None => dismiss_instance_post_upgrade_notice(instance_id, pool).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn post_upgrade_warnings_from_result(
|
||||
result: &crate::install::InstanceUpgradeResult,
|
||||
execution: &crate::install::InstanceUpgradeExecution,
|
||||
) -> Vec<InstancePostUpgradeWarning> {
|
||||
use crate::state::{InstanceUpgradeAction, InstanceUpgradeIssueCode};
|
||||
|
||||
result
|
||||
.compatibility_warning_details
|
||||
.iter()
|
||||
.filter_map(|warning| {
|
||||
if warning.content_id.is_none() && warning.relative_path.is_none() {
|
||||
return None;
|
||||
}
|
||||
let action = warning
|
||||
.content_id
|
||||
.as_ref()
|
||||
.and_then(|content_id| {
|
||||
result
|
||||
.solution
|
||||
.selections
|
||||
.iter()
|
||||
.find(|selection| selection.content_id == *content_id)
|
||||
})
|
||||
.or_else(|| {
|
||||
let provider = warning.provider?;
|
||||
let project_id = warning.project_id.as_deref()?;
|
||||
let mut matches =
|
||||
result.solution.selections.iter().filter(|selection| {
|
||||
selection.provider == Some(provider)
|
||||
&& selection.project_id.as_deref()
|
||||
== Some(project_id)
|
||||
});
|
||||
let selection = matches.next()?;
|
||||
matches.next().is_none().then_some(selection)
|
||||
})
|
||||
.map(|selection| selection.action);
|
||||
let action = action.or_else(|| {
|
||||
let item = warning
|
||||
.content_id
|
||||
.as_ref()
|
||||
.and_then(|content_id| {
|
||||
execution
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.content_id == *content_id)
|
||||
})
|
||||
.or_else(|| {
|
||||
let relative_path = warning.relative_path.as_deref()?;
|
||||
execution
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.relative_path == relative_path)
|
||||
})?;
|
||||
Some(execution.final_physical_decision(item).0)
|
||||
});
|
||||
let code = match action {
|
||||
Some(
|
||||
InstanceUpgradeAction::Upgrade
|
||||
| InstanceUpgradeAction::Disable,
|
||||
) => {
|
||||
return None;
|
||||
}
|
||||
Some(InstanceUpgradeAction::Keep) => match warning.code {
|
||||
InstanceUpgradeIssueCode::PrereleaseOnly
|
||||
| InstanceUpgradeIssueCode::DependencyConflict
|
||||
| InstanceUpgradeIssueCode::MissingRequiredDependency
|
||||
| InstanceUpgradeIssueCode::IncompatibleDependency
|
||||
| InstanceUpgradeIssueCode::SearchLimitReached => {
|
||||
InstanceUpgradeIssueCode::KeepIncompatible
|
||||
}
|
||||
code => code,
|
||||
},
|
||||
None => match warning.code {
|
||||
InstanceUpgradeIssueCode::Unidentified
|
||||
| InstanceUpgradeIssueCode::UnsupportedContentType
|
||||
| InstanceUpgradeIssueCode::KeepIncompatible => {
|
||||
warning.code
|
||||
}
|
||||
_ => return None,
|
||||
},
|
||||
};
|
||||
Some(InstancePostUpgradeWarning {
|
||||
code,
|
||||
content_id: warning.content_id.clone(),
|
||||
relative_path: warning.relative_path.clone(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::install::{
|
||||
InstanceUpgradeCompatibilityWarning, InstanceUpgradeExecution,
|
||||
InstanceUpgradeResult,
|
||||
};
|
||||
use crate::state::{
|
||||
InstanceUpgradeAction, InstanceUpgradeEnvironment,
|
||||
InstanceUpgradeIssueCode, InstanceUpgradeItem,
|
||||
InstanceUpgradeItemStatus, InstanceUpgradeResolution,
|
||||
InstanceUpgradeSelection, InstanceUpgradeSolution,
|
||||
InstanceUpgradeSolutionKind, ModLoader, ProjectType, ShaderRuntime,
|
||||
};
|
||||
|
||||
fn empty_execution() -> InstanceUpgradeExecution {
|
||||
let environment = InstanceUpgradeEnvironment {
|
||||
game_version: "1.21.9".to_string(),
|
||||
mod_loader: ModLoader::Fabric,
|
||||
mod_loader_version: Some("0.18.5".to_string()),
|
||||
shader_runtime: ShaderRuntime::Iris,
|
||||
};
|
||||
InstanceUpgradeExecution {
|
||||
source_revision: 1,
|
||||
source_files: Vec::new(),
|
||||
source_environment: environment.clone(),
|
||||
target_environment: environment,
|
||||
items: Vec::new(),
|
||||
solution: InstanceUpgradeSolution {
|
||||
kind: InstanceUpgradeSolutionKind::Custom,
|
||||
selections: Vec::new(),
|
||||
dependency_changes: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
warnings: Vec::new(),
|
||||
source_watch: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn upgrade_result(
|
||||
entries: &[(
|
||||
&str,
|
||||
InstanceUpgradeIssueCode,
|
||||
InstanceUpgradeAction,
|
||||
Option<&str>,
|
||||
)],
|
||||
) -> InstanceUpgradeResult {
|
||||
InstanceUpgradeResult {
|
||||
plan_id: "plan".to_string(),
|
||||
source_instance_id: "instance".to_string(),
|
||||
target_instance_id: "instance".to_string(),
|
||||
backup_instance_id: None,
|
||||
source_environment: None,
|
||||
target_environment: None,
|
||||
solution: InstanceUpgradeSolution {
|
||||
kind: InstanceUpgradeSolutionKind::Custom,
|
||||
selections: entries
|
||||
.iter()
|
||||
.map(|(content_id, _, action, target_release_id)| {
|
||||
InstanceUpgradeSelection {
|
||||
content_id: (*content_id).to_string(),
|
||||
provider: None,
|
||||
project_id: None,
|
||||
current_release_id: None,
|
||||
target_release_id: target_release_id
|
||||
.map(str::to_string),
|
||||
action: *action,
|
||||
enabled: *action != InstanceUpgradeAction::Disable,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
dependency_changes: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
},
|
||||
compatibility_warnings: Vec::new(),
|
||||
compatibility_warning_details: entries
|
||||
.iter()
|
||||
.map(|(content_id, code, _, _)| {
|
||||
InstanceUpgradeCompatibilityWarning {
|
||||
code: *code,
|
||||
relative_path: Some(format!("mods/{content_id}.jar")),
|
||||
content_id: Some((*content_id).to_string()),
|
||||
provider: None,
|
||||
project_id: None,
|
||||
conflicting_project_id: None,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
external_changes: Vec::new(),
|
||||
skipped_due_to_external_conflict: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn notice(clean_launches: u8) -> InstancePostUpgradeNotice {
|
||||
InstancePostUpgradeNotice {
|
||||
instance_id: "instance".to_string(),
|
||||
upgrade_job_id: "job".to_string(),
|
||||
target_game_version: "26.2".to_string(),
|
||||
consecutive_clean_launches: clean_launches,
|
||||
warnings: vec![InstancePostUpgradeWarning {
|
||||
code: InstanceUpgradeIssueCode::KeepIncompatible,
|
||||
content_id: Some("content".to_string()),
|
||||
relative_path: Some("mods/example.jar".to_string()),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_launch_expires_notice_after_two_consecutive_sessions() {
|
||||
let first = next_post_upgrade_notice_after_launch(notice(0), true)
|
||||
.expect("first launch keeps notice");
|
||||
assert_eq!(first.consecutive_clean_launches, 1);
|
||||
assert!(next_post_upgrade_notice_after_launch(first, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_launch_resets_consecutive_count() {
|
||||
let reset = next_post_upgrade_notice_after_launch(notice(1), false)
|
||||
.expect("failed launch keeps notice");
|
||||
assert_eq!(reset.consecutive_clean_launches, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgraded_prerelease_history_is_not_a_post_upgrade_risk() {
|
||||
let result = upgrade_result(&[(
|
||||
"voxy",
|
||||
InstanceUpgradeIssueCode::PrereleaseOnly,
|
||||
InstanceUpgradeAction::Upgrade,
|
||||
Some("target-alpha"),
|
||||
)]);
|
||||
|
||||
assert!(
|
||||
post_upgrade_warnings_from_result(&result, &empty_execution())
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(result.compatibility_warning_details.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_prerelease_is_reported_as_incompatible_preserved_content() {
|
||||
let result = upgrade_result(&[(
|
||||
"voxy",
|
||||
InstanceUpgradeIssueCode::PrereleaseOnly,
|
||||
InstanceUpgradeAction::Keep,
|
||||
None,
|
||||
)]);
|
||||
|
||||
let warnings =
|
||||
post_upgrade_warnings_from_result(&result, &empty_execution());
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert_eq!(
|
||||
warnings[0].code,
|
||||
InstanceUpgradeIssueCode::KeepIncompatible
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_no_release_and_unidentified_content_remain_risks() {
|
||||
let result = upgrade_result(&[
|
||||
(
|
||||
"resource-pack",
|
||||
InstanceUpgradeIssueCode::NoCompatibleRelease,
|
||||
InstanceUpgradeAction::Keep,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"local-jar",
|
||||
InstanceUpgradeIssueCode::Unidentified,
|
||||
InstanceUpgradeAction::Keep,
|
||||
None,
|
||||
),
|
||||
]);
|
||||
|
||||
let warnings =
|
||||
post_upgrade_warnings_from_result(&result, &empty_execution());
|
||||
assert_eq!(warnings.len(), 2);
|
||||
assert!(warnings.iter().any(|warning| {
|
||||
warning.code == InstanceUpgradeIssueCode::NoCompatibleRelease
|
||||
}));
|
||||
assert!(warnings.iter().any(|warning| {
|
||||
warning.code == InstanceUpgradeIssueCode::Unidentified
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_content_is_not_a_post_upgrade_risk() {
|
||||
let result = upgrade_result(&[(
|
||||
"disabled",
|
||||
InstanceUpgradeIssueCode::NoCompatibleRelease,
|
||||
InstanceUpgradeAction::Disable,
|
||||
None,
|
||||
)]);
|
||||
|
||||
assert!(
|
||||
post_upgrade_warnings_from_result(&result, &empty_execution())
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_history_warning_is_not_a_content_notice() {
|
||||
let mut result = upgrade_result(&[(
|
||||
"global",
|
||||
InstanceUpgradeIssueCode::KeepIncompatible,
|
||||
InstanceUpgradeAction::Keep,
|
||||
None,
|
||||
)]);
|
||||
result.compatibility_warning_details[0].content_id = None;
|
||||
result.compatibility_warning_details[0].relative_path = None;
|
||||
|
||||
assert!(
|
||||
post_upgrade_warnings_from_result(&result, &empty_execution())
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_final_actions_only_report_kept_content() {
|
||||
let result = upgrade_result(&[
|
||||
(
|
||||
"upgraded",
|
||||
InstanceUpgradeIssueCode::PrereleaseOnly,
|
||||
InstanceUpgradeAction::Upgrade,
|
||||
Some("target-alpha"),
|
||||
),
|
||||
(
|
||||
"kept",
|
||||
InstanceUpgradeIssueCode::NoCompatibleRelease,
|
||||
InstanceUpgradeAction::Keep,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"disabled",
|
||||
InstanceUpgradeIssueCode::NoCompatibleRelease,
|
||||
InstanceUpgradeAction::Disable,
|
||||
None,
|
||||
),
|
||||
]);
|
||||
|
||||
let warnings =
|
||||
post_upgrade_warnings_from_result(&result, &empty_execution());
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert_eq!(warnings[0].content_id.as_deref(), Some("kept"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_solver_local_notice_uses_execution_item_resolution() {
|
||||
let mut result = upgrade_result(&[(
|
||||
"local",
|
||||
InstanceUpgradeIssueCode::Unidentified,
|
||||
InstanceUpgradeAction::Keep,
|
||||
None,
|
||||
)]);
|
||||
result.solution.selections.clear();
|
||||
let mut execution = empty_execution();
|
||||
execution.items.push(InstanceUpgradeItem {
|
||||
content_id: "local".to_string(),
|
||||
relative_path: "mods/local.jar".to_string(),
|
||||
project_type: ProjectType::Mod,
|
||||
provider: None,
|
||||
project_id: None,
|
||||
current_release_id: None,
|
||||
current_enabled: true,
|
||||
auto_dependency: false,
|
||||
status: InstanceUpgradeItemStatus::Unidentified,
|
||||
resolution: InstanceUpgradeResolution {
|
||||
content_id: "local".to_string(),
|
||||
action: InstanceUpgradeAction::Keep,
|
||||
allow_prerelease: false,
|
||||
confirmed_prerelease_dependencies: Vec::new(),
|
||||
},
|
||||
candidate_release_ids: Vec::new(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
post_upgrade_warnings_from_result(&result, &execution).len(),
|
||||
1
|
||||
);
|
||||
execution.items[0].resolution.action = InstanceUpgradeAction::Disable;
|
||||
assert!(
|
||||
post_upgrade_warnings_from_result(&result, &execution).is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dismiss_removes_persisted_notice() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("CREATE TABLE instances (id TEXT PRIMARY KEY NOT NULL)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO instances (id) VALUES ('instance')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE instance_post_upgrade_notices (instance_id TEXT PRIMARY KEY NOT NULL REFERENCES instances(id) ON DELETE CASCADE, upgrade_job_id TEXT NOT NULL, target_game_version TEXT NOT NULL, consecutive_clean_launches INTEGER NOT NULL DEFAULT 0, warnings_json TEXT NOT NULL, created TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, modified TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
replace_instance_post_upgrade_notice(¬ice(0), &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
get_instance_post_upgrade_notice("instance", &pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
dismiss_instance_post_upgrade_notice("instance", &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
get_instance_post_upgrade_notice("instance", &pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use crate::State;
|
||||
use crate::state::LauncherFeatureVersion;
|
||||
|
||||
use super::edit_instance::EditInstance;
|
||||
|
||||
pub(crate) async fn refresh_all_instances() -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let instances = crate::state::instances::adapters::sqlite::instance_rows::list_instances(
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
for instance in instances {
|
||||
let launcher_feature_version = (instance.launcher_feature_version
|
||||
< LauncherFeatureVersion::MOST_RECENT)
|
||||
.then_some(LauncherFeatureVersion::MOST_RECENT);
|
||||
|
||||
if launcher_feature_version.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
super::edit_instance::edit_instance(
|
||||
&instance.id,
|
||||
EditInstance {
|
||||
install_stage: None,
|
||||
launcher_feature_version,
|
||||
..EditInstance::default()
|
||||
},
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use crate::state::State;
|
||||
use crate::state::instances::adapters::sqlite::instance_rows;
|
||||
use crate::state::instances::config_sync;
|
||||
use crate::util::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) async fn remove_instance(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<()> {
|
||||
let _instance_lock = state.lock_instance_content(instance_id).await;
|
||||
|
||||
let instance = instance_rows::get_instance_by_id(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
|
||||
// Directly associated instances have no Axolotl profile directory. Their
|
||||
// version directory is the instance itself, so removal deliberately
|
||||
// deletes the externally managed content in place. Keep the shared
|
||||
// `.minecraft` root (assets/libraries/other versions) intact.
|
||||
let path = if instance.is_direct_linked() {
|
||||
crate::launcher::DirectLinkedLaunch::from_instance(&instance)?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::LauncherError(
|
||||
"Direct instance link metadata is incomplete".to_string(),
|
||||
)
|
||||
})?
|
||||
.version_dir()
|
||||
} else if let Some(game_dir_override) = instance
|
||||
.game_dir_override
|
||||
.as_deref()
|
||||
.map(PathBuf::from)
|
||||
.filter(|path| is_version_isolated_game_dir(path))
|
||||
{
|
||||
// New instances created against a configured `.minecraft` root use
|
||||
// a private `versions/<name>` directory. Remove that external
|
||||
// directory when the instance is deleted, while preserving shared
|
||||
// (non-isolated) overrides for backwards compatibility.
|
||||
game_dir_override
|
||||
} else {
|
||||
state.directories.instances_dir().join(&instance.path)
|
||||
};
|
||||
if path.exists() {
|
||||
io::remove_dir_all(&path).await?;
|
||||
}
|
||||
|
||||
let jobs = crate::install::store::mark_instance_deleted(instance_id, state)
|
||||
.await?;
|
||||
instance_rows::delete_instance_by_id(&instance.id, &state.pool).await?;
|
||||
config_sync::remove_config_file(&state.directories, &instance.path).await?;
|
||||
for job in jobs {
|
||||
if let Err(error) =
|
||||
crate::install::events::emit_install_job(&job.snapshot()).await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to emit deleted instance download state: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_version_isolated_game_dir(path: &Path) -> bool {
|
||||
path.parent()
|
||||
.and_then(Path::file_name)
|
||||
.and_then(|name| name.to_str())
|
||||
== Some("versions")
|
||||
}
|
||||
@@ -0,0 +1,974 @@
|
||||
use crate::State;
|
||||
use crate::state::instances::adapters::{filesystem, sqlite};
|
||||
use crate::state::instances::{Instance, InstanceFile};
|
||||
use crate::state::{
|
||||
CacheBehaviour, CachedEntry, CachedFileUpdate, ContentProvider,
|
||||
ContentProviderRef, DirectoryInfo, ModrinthVersionId, ProjectType,
|
||||
};
|
||||
use crate::util::fetch::{self, FetchSemaphore};
|
||||
use crate::util::io;
|
||||
use chrono::Utc;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Resolves the directory whose game content (`mods`, `resourcepacks`, ...)
|
||||
/// belongs to this instance.
|
||||
///
|
||||
/// Ordinary instances own their profile directory under Axolotl's instances
|
||||
/// folder, honouring a per-instance `game_dir_override`
|
||||
/// (`DirectoryInfo::instance_game_dir`). Directly associated instances have no
|
||||
/// profile directory: their content lives inside the externally managed
|
||||
/// installation, resolved through the launcher dialect so PCL version
|
||||
/// isolation (`versions/<id>` gameDir) is honored; the shared linked
|
||||
/// `.minecraft` root is the fallback when the dialect resolution cannot be
|
||||
/// completed (see `launcher::linked_game_dir`).
|
||||
pub(crate) fn instance_content_root(
|
||||
directories: &DirectoryInfo,
|
||||
instance: &Instance,
|
||||
) -> crate::Result<PathBuf> {
|
||||
if let Some(game_dir) =
|
||||
crate::launcher::linked_game_dir(instance).or_else(|| {
|
||||
instance
|
||||
.linked_dot_minecraft
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|linked| !linked.is_empty())
|
||||
.map(PathBuf::from)
|
||||
})
|
||||
{
|
||||
return Ok(io::canonicalize(game_dir)?);
|
||||
}
|
||||
|
||||
Ok(io::canonicalize(directories.instance_game_dir(instance))?)
|
||||
}
|
||||
|
||||
/// Joins a stored logical relative path using native path components. Content
|
||||
/// rows intentionally use `/` as their cross-platform serialization format;
|
||||
/// filesystem access must not concatenate that representation directly.
|
||||
pub(crate) fn join_content_path(root: &Path, relative: &str) -> PathBuf {
|
||||
relative
|
||||
.split(['/', '\\'])
|
||||
.filter(|component| !component.is_empty() && *component != ".")
|
||||
.fold(root.to_path_buf(), |path, component| path.join(component))
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_content_files(
|
||||
instance_id: &str,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<InstanceFile>> {
|
||||
let instance =
|
||||
sqlite::instance_rows::get_instance_by_id(instance_id, &state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
||||
})?;
|
||||
|
||||
sync_instance_content_files(&instance, state).await
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_instance_content_files(
|
||||
instance: &Instance,
|
||||
state: &State,
|
||||
) -> crate::Result<Vec<InstanceFile>> {
|
||||
// Keep the filesystem snapshot stable until its database rows commit.
|
||||
let _instance_lock = state.lock_instance_content(&instance.id).await;
|
||||
let content_root = instance_content_root(&state.directories, instance)?;
|
||||
// The hash-cache layer resolves key paths against Axolotl's own instances
|
||||
// folder (`state/cache.rs`). For directly associated instances the
|
||||
// absolute linked root is passed as the "instance path": joining an
|
||||
// absolute path replaces the base, so the cache layer hashes the linked
|
||||
// files instead of failing on the nonexistent profile path. Managed
|
||||
// instances keep the relative profile path so the cache layer can resolve
|
||||
// a `game_dir_override` target from the database.
|
||||
let is_direct_linked = crate::launcher::linked_game_dir(instance).is_some()
|
||||
|| instance
|
||||
.linked_dot_minecraft
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|linked| !linked.is_empty());
|
||||
let instance_files_root = if is_direct_linked {
|
||||
content_root.clone()
|
||||
} else {
|
||||
state.directories.instances_dir().join(&instance.path)
|
||||
};
|
||||
let cache_key_path = if is_direct_linked {
|
||||
content_root.to_string_lossy().into_owned()
|
||||
} else {
|
||||
instance.path.clone()
|
||||
};
|
||||
let scanned =
|
||||
filesystem::scan_content_files_from(&content_root, &cache_key_path)?;
|
||||
let scanned_paths = scanned
|
||||
.iter()
|
||||
.map(|file| file.relative_path.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
let cache_keys = scanned
|
||||
.iter()
|
||||
.map(|file| file.hash_cache_key.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
let hashes = CachedEntry::get_file_hash_many(
|
||||
&cache_keys,
|
||||
None,
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
)
|
||||
.await?;
|
||||
let hashes_by_key = hashes
|
||||
.into_iter()
|
||||
.map(|hash| {
|
||||
(
|
||||
format!(
|
||||
"{}-{}",
|
||||
hash.size,
|
||||
hash.path.trim_end_matches(".disabled")
|
||||
),
|
||||
hash,
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let existing_files =
|
||||
sqlite::content_rows::get_instance_files(&instance.id, &state.pool)
|
||||
.await?;
|
||||
let mut existing_files_by_path = HashMap::new();
|
||||
let mut existing_files_by_sha1: HashMap<String, Vec<InstanceFile>> =
|
||||
HashMap::new();
|
||||
for file in existing_files {
|
||||
existing_files_by_sha1
|
||||
.entry(file.sha1.clone())
|
||||
.or_default()
|
||||
.push(file.clone());
|
||||
existing_files_by_path.insert(file.relative_path.clone(), file);
|
||||
}
|
||||
let content_set = sqlite::content_rows::get_applied_content_set(
|
||||
&instance.id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
let entry_file_ids = match content_set.as_ref() {
|
||||
Some(content_set) => sqlite::content_rows::get_content_entries(
|
||||
&content_set.id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|entry| entry.file_id)
|
||||
.collect(),
|
||||
None => HashSet::new(),
|
||||
};
|
||||
|
||||
let now = Utc::now();
|
||||
let mut files: Vec<InstanceFile> = Vec::new();
|
||||
let mut reclaims: HashMap<String, String> = HashMap::new();
|
||||
let mut merges: HashMap<String, String> = HashMap::new();
|
||||
let mut claimed_reclaim_ids = HashSet::new();
|
||||
let mut externally_changed_file_ids = HashSet::new();
|
||||
|
||||
for file in scanned {
|
||||
let hash_key = file.hash_cache_key.trim_end_matches(".disabled");
|
||||
let existing_file = existing_files_by_path.get(&file.relative_path);
|
||||
let (scanned_sha1, scanned_size) = if existing_file.is_some() {
|
||||
let path =
|
||||
join_content_path(&instance_files_root, &file.relative_path);
|
||||
let (_, sha1) = fetch::sha1_file_async(&path).await?;
|
||||
(sha1, file.size)
|
||||
} else {
|
||||
let Some(hash) = hashes_by_key.get(hash_key) else {
|
||||
continue;
|
||||
};
|
||||
(hash.hash.clone(), hash.size)
|
||||
};
|
||||
let reclaim_candidate = if existing_file.is_some() {
|
||||
None
|
||||
} else {
|
||||
reclaimable_existing_file(
|
||||
&scanned_sha1,
|
||||
&file.relative_path,
|
||||
&existing_files_by_sha1,
|
||||
&scanned_paths,
|
||||
&claimed_reclaim_ids,
|
||||
)
|
||||
};
|
||||
let merge_candidate = existing_file
|
||||
.filter(|file| !entry_file_ids.contains(&file.id))
|
||||
.and_then(|file| {
|
||||
mergeable_tracked_file(
|
||||
&scanned_sha1,
|
||||
&file.relative_path,
|
||||
&file.id,
|
||||
&existing_files_by_sha1,
|
||||
&entry_file_ids,
|
||||
&scanned_paths,
|
||||
&claimed_reclaim_ids,
|
||||
)
|
||||
});
|
||||
let source_file = existing_file.or(reclaim_candidate);
|
||||
if let Some(existing_file) = existing_file
|
||||
&& physical_file_identity_changed(
|
||||
existing_file,
|
||||
&scanned_sha1,
|
||||
scanned_size,
|
||||
)
|
||||
{
|
||||
externally_changed_file_ids.insert(existing_file.id.clone());
|
||||
}
|
||||
if let Some(candidate) = reclaim_candidate {
|
||||
claimed_reclaim_ids.insert(candidate.id.clone());
|
||||
reclaims.insert(
|
||||
file.relative_path.clone(),
|
||||
candidate.relative_path.clone(),
|
||||
);
|
||||
}
|
||||
if let Some(candidate) = merge_candidate {
|
||||
claimed_reclaim_ids.insert(candidate.id.clone());
|
||||
merges.insert(file.relative_path.clone(), candidate.id.clone());
|
||||
}
|
||||
|
||||
files.push(InstanceFile {
|
||||
id: source_file
|
||||
.map(|file| file.id.clone())
|
||||
.unwrap_or_else(instance_file_id),
|
||||
instance_id: instance.id.clone(),
|
||||
relative_path: file.relative_path,
|
||||
file_name: file.file_name,
|
||||
enabled: file.enabled,
|
||||
sha1: scanned_sha1,
|
||||
size: scanned_size,
|
||||
missing: false,
|
||||
added_at: source_file.map(|file| file.added_at).unwrap_or(now),
|
||||
modified_at: now,
|
||||
local_mod_data: source_file.and_then(|f| f.local_mod_data.clone()),
|
||||
icon_path: source_file.and_then(|f| f.icon_path.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
// Extract local mod metadata (Mod JARs) and cached icons (Mod JARs and
|
||||
// resource packs) for files that don't have them yet. This also backfills
|
||||
// rows created before these features existed; `icon_path` distinguishes
|
||||
// not-attempted (NULL), no-icon (empty string), and cached (path).
|
||||
// `content_root` already resolves the override / linked root consistently
|
||||
// for both managed and directly associated instances.
|
||||
let instance_dir = content_root;
|
||||
let icon_cache_dir = state.directories.caches_dir().join("icons");
|
||||
for file in &mut files {
|
||||
let Some(project_type) = project_type_for_file(file) else {
|
||||
continue;
|
||||
};
|
||||
// Re-extract metadata written before dependency extraction existed:
|
||||
// legacy JSON parses with `dependencies: None` and needs one pass
|
||||
// through the updated extractor.
|
||||
let extract_metadata = project_type == ProjectType::Mod
|
||||
&& file
|
||||
.local_mod_data
|
||||
.as_ref()
|
||||
.and_then(|json| {
|
||||
serde_json::from_str::<
|
||||
crate::mod_metadata::LocalModMetadata,
|
||||
>(json)
|
||||
.ok()
|
||||
})
|
||||
.and_then(|metadata| metadata.dependencies)
|
||||
.is_none();
|
||||
let extract_icon = file.icon_path.is_none()
|
||||
&& matches!(
|
||||
project_type,
|
||||
ProjectType::Mod | ProjectType::ResourcePack
|
||||
);
|
||||
if !extract_metadata && !extract_icon {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = join_content_path(&instance_dir, &file.relative_path);
|
||||
|
||||
// Resource packs are read entry-wise so large archives are not
|
||||
// materialized in memory just to fetch `pack.png`.
|
||||
if extract_icon && project_type == ProjectType::ResourcePack {
|
||||
let icon =
|
||||
crate::mod_metadata::icon::extract_resource_pack_icon(&path);
|
||||
file.icon_path = Some(
|
||||
cache_extracted_icon(icon, &file.sha1, &icon_cache_dir, state)
|
||||
.await,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mods: one in-memory read serves both metadata and icon extraction.
|
||||
let bytes = match tokio::fs::read(&path).await {
|
||||
Ok(data) => bytes::Bytes::from(data),
|
||||
Err(_) => {
|
||||
// File temporarily inaccessible; skip silently.
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if extract_metadata
|
||||
&& let Some(meta) =
|
||||
crate::mod_metadata::extract_mod_metadata(&bytes)
|
||||
&& let Ok(json) = serde_json::to_string(&meta)
|
||||
{
|
||||
file.local_mod_data = Some(json);
|
||||
}
|
||||
|
||||
if extract_icon {
|
||||
let meta = file.local_mod_data.as_ref().and_then(|json| {
|
||||
serde_json::from_str::<crate::mod_metadata::LocalModMetadata>(
|
||||
json,
|
||||
)
|
||||
.ok()
|
||||
});
|
||||
let icon = crate::mod_metadata::icon::extract_mod_icon(
|
||||
&bytes,
|
||||
meta.as_ref(),
|
||||
);
|
||||
file.icon_path = Some(
|
||||
cache_extracted_icon(icon, &file.sha1, &icon_cache_dir, state)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx = state.pool.begin_with("BEGIN IMMEDIATE").await?;
|
||||
sqlite::content_rows::ensure_instance_exists(&instance.id, &mut tx).await?;
|
||||
sqlite::content_rows::mark_instance_files_missing(&instance.id, &mut tx)
|
||||
.await?;
|
||||
let mut invalidated_provider_identity = false;
|
||||
if let Some(content_set) = content_set.as_ref() {
|
||||
for file_id in &externally_changed_file_ids {
|
||||
invalidated_provider_identity |= sqlite::content_rows::invalidate_exact_provider_refs_for_file_in_transaction(
|
||||
&content_set.id,
|
||||
file_id,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert with a fresh id lookup inside the transaction. The ids assigned
|
||||
// during the scan may be stale if a concurrent operation (e.g. batch
|
||||
// disable renaming files to `.disabled`) moved a row after the snapshot;
|
||||
// reusing a stale id against the moved row would trip the UNIQUE
|
||||
// constraint on `instance_files.id` (code 1555).
|
||||
let mut synced_files: Vec<InstanceFile> = Vec::with_capacity(files.len());
|
||||
for file in &files {
|
||||
let synced =
|
||||
if let Some(tracked_file_id) = merges.get(&file.relative_path) {
|
||||
sqlite::content_rows::adopt_untracked_file_in_transaction(
|
||||
&instance.id,
|
||||
&file.relative_path,
|
||||
tracked_file_id,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
upsert_scanned_file(&instance.id, file, &mut tx).await?
|
||||
} else if let Some(old_relative_path) =
|
||||
reclaims.get(&file.relative_path)
|
||||
{
|
||||
match sqlite::content_rows::move_instance_file_in_transaction(
|
||||
&instance.id,
|
||||
old_relative_path,
|
||||
&file.relative_path,
|
||||
&file.file_name,
|
||||
file.enabled,
|
||||
&file.sha1,
|
||||
file.size,
|
||||
file.local_mod_data.as_deref(),
|
||||
file.icon_path.as_deref(),
|
||||
&mut tx,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(file) => file,
|
||||
None => {
|
||||
upsert_scanned_file(&instance.id, file, &mut tx).await?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
upsert_scanned_file(&instance.id, file, &mut tx).await?
|
||||
};
|
||||
synced_files.push(synced);
|
||||
}
|
||||
|
||||
if invalidated_provider_identity
|
||||
&& let Some(content_set) = content_set.as_ref()
|
||||
{
|
||||
sqlite::content_rows::bump_content_set_revision_in_transaction(
|
||||
&content_set.id,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(synced_files)
|
||||
}
|
||||
|
||||
async fn cache_extracted_icon(
|
||||
icon: Option<(String, Vec<u8>)>,
|
||||
sha1: &str,
|
||||
icon_cache_dir: &Path,
|
||||
state: &State,
|
||||
) -> String {
|
||||
let Some((entry_name, icon_bytes)) = icon else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let extension = icon_extension(&entry_name);
|
||||
let cache_path = icon_cache_dir.join(format!("{sha1}.{extension}"));
|
||||
match fetch::write(&cache_path, &icon_bytes, &state.io_semaphore).await {
|
||||
Ok(()) => crate::util::io::canonicalize(&cache_path)
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|_| cache_path.to_string_lossy().into_owned()),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn icon_extension(entry_name: &str) -> &str {
|
||||
let extension = Path::new(entry_name)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or_default();
|
||||
if matches!(
|
||||
extension.to_ascii_lowercase().as_str(),
|
||||
"png" | "jpg" | "jpeg"
|
||||
) {
|
||||
extension
|
||||
} else {
|
||||
"png"
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn project_type_for_file(
|
||||
file: &InstanceFile,
|
||||
) -> Option<ProjectType> {
|
||||
filesystem::project_type_from_relative_path(&file.relative_path)
|
||||
}
|
||||
|
||||
pub(crate) fn installed_modrinth_version_id(
|
||||
provider_refs: &[ContentProviderRef],
|
||||
) -> Option<ModrinthVersionId> {
|
||||
provider_refs.iter().find_map(|reference| match reference {
|
||||
ContentProviderRef::Modrinth { version_id, .. } => {
|
||||
version_id.as_ref().cloned()
|
||||
}
|
||||
ContentProviderRef::CurseForge { .. }
|
||||
| ContentProviderRef::McArchive { .. } => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch_content_file_updates(
|
||||
update_key_refs: &[&str],
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
refresh: bool,
|
||||
pool: &sqlx::SqlitePool,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<Vec<CachedFileUpdate>> {
|
||||
let update_behaviour = if refresh {
|
||||
Some(CacheBehaviour::Bypass)
|
||||
} else {
|
||||
cache_behaviour
|
||||
};
|
||||
|
||||
match CachedEntry::get_file_update_many(
|
||||
update_key_refs,
|
||||
update_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(updates) => Ok(updates),
|
||||
Err(error) if refresh => {
|
||||
tracing::warn!(
|
||||
"Content update refresh failed, using cached update data: {error}"
|
||||
);
|
||||
CachedEntry::get_file_update_many(
|
||||
update_key_refs,
|
||||
Some(CacheBehaviour::CacheOnly),
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a file may receive Modrinth update suggestions.
|
||||
///
|
||||
/// Files installed from Modrinth (origin `Modrinth`) always qualify. Untracked
|
||||
/// or locally recorded files (no origin) qualify as long as no CurseForge
|
||||
/// reference ties them to a different provider; CurseForge-origin files are
|
||||
/// handled by the CurseForge update path instead.
|
||||
pub(crate) fn modrinth_update_enabled(
|
||||
origin_provider: Option<ContentProvider>,
|
||||
provider_refs: &[ContentProviderRef],
|
||||
) -> bool {
|
||||
match origin_provider {
|
||||
Some(ContentProvider::Modrinth) => true,
|
||||
Some(ContentProvider::CurseForge)
|
||||
| Some(ContentProvider::McArchive)
|
||||
| Some(ContentProvider::Local) => false,
|
||||
None => provider_refs.iter().all(|reference| {
|
||||
matches!(reference, ContentProviderRef::Modrinth { .. })
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn instance_file_id() -> String {
|
||||
format!("instance-file:{}", Uuid::new_v4())
|
||||
}
|
||||
|
||||
fn physical_file_identity_changed(
|
||||
existing: &InstanceFile,
|
||||
scanned_sha1: &str,
|
||||
scanned_size: u64,
|
||||
) -> bool {
|
||||
existing.sha1 != scanned_sha1 || existing.size != scanned_size
|
||||
}
|
||||
|
||||
async fn upsert_scanned_file(
|
||||
instance_id: &str,
|
||||
file: &InstanceFile,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
) -> crate::Result<InstanceFile> {
|
||||
sqlite::content_rows::upsert_instance_file_from_parts_in_transaction(
|
||||
sqlite::content_rows::UpsertInstanceFile {
|
||||
instance_id,
|
||||
relative_path: &file.relative_path,
|
||||
file_name: &file.file_name,
|
||||
enabled: file.enabled,
|
||||
sha1: &file.sha1,
|
||||
size: file.size,
|
||||
missing: false,
|
||||
local_mod_data: file.local_mod_data.as_deref(),
|
||||
icon_path: file.icon_path.as_deref(),
|
||||
},
|
||||
tx,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Finds the single missing file row that can safely inherit a scanned file's
|
||||
/// installed identity. A rename should keep its provider refs, entry, and
|
||||
/// install history; ambiguous duplicates and cross-type moves stay untracked.
|
||||
fn reclaimable_existing_file<'a>(
|
||||
sha1: &str,
|
||||
new_relative_path: &str,
|
||||
existing_files_by_sha1: &'a HashMap<String, Vec<InstanceFile>>,
|
||||
scanned_paths: &HashSet<String>,
|
||||
claimed_ids: &HashSet<String>,
|
||||
) -> Option<&'a InstanceFile> {
|
||||
let candidates = existing_files_by_sha1.get(sha1)?;
|
||||
if candidates
|
||||
.iter()
|
||||
.any(|file| scanned_paths.contains(file.relative_path.as_str()))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let missing = candidates
|
||||
.iter()
|
||||
.filter(|file| {
|
||||
file.missing || !scanned_paths.contains(&file.relative_path)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if missing.len() != 1 || claimed_ids.contains(&missing[0].id) {
|
||||
return None;
|
||||
}
|
||||
let candidate = missing[0];
|
||||
let new_type =
|
||||
filesystem::project_type_from_relative_path(new_relative_path)?;
|
||||
let old_type =
|
||||
filesystem::project_type_from_relative_path(&candidate.relative_path)?;
|
||||
(new_type == old_type).then_some(candidate)
|
||||
}
|
||||
|
||||
/// Finds the single tracked row that should hand its installed identity to an
|
||||
/// untracked row at a new path. This repairs instances that were already
|
||||
/// broken by a rename before reclaim-on-move existed.
|
||||
fn mergeable_tracked_file<'a>(
|
||||
sha1: &str,
|
||||
new_relative_path: &str,
|
||||
untracked_file_id: &str,
|
||||
existing_files_by_sha1: &'a HashMap<String, Vec<InstanceFile>>,
|
||||
entry_file_ids: &HashSet<String>,
|
||||
scanned_paths: &HashSet<String>,
|
||||
claimed_ids: &HashSet<String>,
|
||||
) -> Option<&'a InstanceFile> {
|
||||
let candidates = existing_files_by_sha1.get(sha1)?;
|
||||
let tracked = candidates
|
||||
.iter()
|
||||
.filter(|file| entry_file_ids.contains(&file.id))
|
||||
.collect::<Vec<_>>();
|
||||
if tracked.is_empty()
|
||||
|| tracked.iter().any(|file| {
|
||||
file.id != untracked_file_id
|
||||
&& scanned_paths.contains(file.relative_path.as_str())
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let missing = tracked
|
||||
.iter()
|
||||
.filter(|file| {
|
||||
file.id != untracked_file_id
|
||||
&& (file.missing
|
||||
|| !scanned_paths.contains(file.relative_path.as_str()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if missing.len() != 1 || claimed_ids.contains(&missing[0].id) {
|
||||
return None;
|
||||
}
|
||||
let candidate = missing[0];
|
||||
let new_type =
|
||||
filesystem::project_type_from_relative_path(new_relative_path)?;
|
||||
let old_type =
|
||||
filesystem::project_type_from_relative_path(&candidate.relative_path)?;
|
||||
(new_type == old_type).then_some(candidate)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::{
|
||||
CurseForgeFileId, CurseForgeProjectId, ModrinthProjectId,
|
||||
ModrinthVersionId,
|
||||
};
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn modrinth_ref() -> ContentProviderRef {
|
||||
ContentProviderRef::Modrinth {
|
||||
project_id: ModrinthProjectId::new("project").unwrap(),
|
||||
version_id: Some(ModrinthVersionId::new("version").unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
fn curseforge_ref() -> ContentProviderRef {
|
||||
ContentProviderRef::CurseForge {
|
||||
project_id: CurseForgeProjectId::new(42).unwrap(),
|
||||
file_id: Some(CurseForgeFileId::new(7).unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untracked_files_qualify_for_modrinth_updates() {
|
||||
assert!(modrinth_update_enabled(None, &[]));
|
||||
assert!(modrinth_update_enabled(None, &[modrinth_ref()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curseforge_tracked_files_do_not_qualify_for_modrinth_updates() {
|
||||
assert!(!modrinth_update_enabled(
|
||||
Some(ContentProvider::CurseForge),
|
||||
&[curseforge_ref()],
|
||||
));
|
||||
assert!(!modrinth_update_enabled(None, &[curseforge_ref()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modrinth_origin_always_qualifies() {
|
||||
assert!(modrinth_update_enabled(
|
||||
Some(ContentProvider::Modrinth),
|
||||
&[curseforge_ref(), modrinth_ref()],
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_scan_preserves_install_temporary_files() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mods = root.path().join("instance/mods");
|
||||
fs::create_dir_all(&mods).unwrap();
|
||||
let temporary_names = [
|
||||
"example.jar.installing.download",
|
||||
"example.jar.installing",
|
||||
"example.jar.installing.previous",
|
||||
];
|
||||
for name in temporary_names {
|
||||
fs::write(mods.join(name), name).unwrap();
|
||||
}
|
||||
|
||||
let scanned =
|
||||
filesystem::scan_content_files(root.path(), "instance").unwrap();
|
||||
|
||||
assert!(scanned.is_empty());
|
||||
for name in temporary_names {
|
||||
assert!(mods.join(name).is_file());
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Directly associated instances scan their linked installation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// The launcher state is a process-wide singleton; initialize it once and
|
||||
/// reuse it so `State::get()` (used by the hash cache) resolves inside
|
||||
/// these APIs. The state root is intentionally leaked (`.keep()`) because
|
||||
/// the shared state outlives this function.
|
||||
async fn global_state() -> Arc<State> {
|
||||
if !State::initialized() {
|
||||
let root = tempfile::TempDir::new().unwrap().keep();
|
||||
let _ =
|
||||
State::init_for_test(root.to_string_lossy().to_string()).await;
|
||||
}
|
||||
State::get().await.unwrap()
|
||||
}
|
||||
|
||||
fn write_self_contained_version(minecraft: &Path, version_id: &str) {
|
||||
let version_dir = minecraft.join("versions").join(version_id);
|
||||
fs::create_dir_all(&version_dir).unwrap();
|
||||
fs::write(
|
||||
version_dir.join(format!("{version_id}.json")),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"id": version_id,
|
||||
"mainClass": "net.minecraft.client.main.Main"
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_link_refresh_scans_linked_dot_minecraft_content() {
|
||||
let state = global_state().await;
|
||||
let minecraft = tempfile::TempDir::new().unwrap();
|
||||
write_self_contained_version(minecraft.path(), "1.12.2-linked");
|
||||
fs::create_dir_all(minecraft.path().join("mods")).unwrap();
|
||||
fs::write(
|
||||
minecraft.path().join("mods/linked-mod.jar"),
|
||||
b"linked mod bytes",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let instance = crate::state::create_direct_link_instance(
|
||||
crate::state::CreateDirectLinkInstance {
|
||||
name: None,
|
||||
launcher_type:
|
||||
crate::api::pack::import::ImportLauncherType::Generic,
|
||||
base_path: minecraft.path().to_path_buf(),
|
||||
instance_folder: "versions/1.12.2-linked".to_string(),
|
||||
instance_path: None,
|
||||
game_dir_mode: None,
|
||||
},
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let files = sync_instance_content_files(&instance, &state)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.any(|file| file.relative_path == "mods/linked-mod.jar"),
|
||||
"refresh must discover mods inside the linked `.minecraft`, got: {:?}",
|
||||
files
|
||||
.iter()
|
||||
.map(|file| &file.relative_path)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
// A second refresh re-hashes the existing row. It must continue to
|
||||
// use the linked external root rather than the Axolotl profile path.
|
||||
let refreshed = sync_instance_content_files(&instance, &state)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
refreshed
|
||||
.iter()
|
||||
.any(|file| file.relative_path == "mods/linked-mod.jar")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pcl_isolated_direct_link_scans_version_isolated_content() {
|
||||
let state = global_state().await;
|
||||
let minecraft = tempfile::TempDir::new().unwrap();
|
||||
write_self_contained_version(minecraft.path(), "1.12.2-pcl");
|
||||
// Version isolation on: PCL resolves this version's gameDir to
|
||||
// versions/<id>, so its mods folder lives beside the version JSON.
|
||||
let version_dir = minecraft.path().join("versions/1.12.2-pcl");
|
||||
fs::create_dir_all(version_dir.join("PCL")).unwrap();
|
||||
fs::write(
|
||||
version_dir.join("PCL/Setup.ini"),
|
||||
"VersionArgumentIndieV2: true\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(version_dir.join("mods")).unwrap();
|
||||
fs::write(
|
||||
version_dir.join("mods/isolated-mod.jar"),
|
||||
b"isolated mod bytes",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let instance = crate::state::create_direct_link_instance(
|
||||
crate::state::CreateDirectLinkInstance {
|
||||
name: None,
|
||||
launcher_type:
|
||||
crate::api::pack::import::ImportLauncherType::PCL2,
|
||||
base_path: minecraft.path().to_path_buf(),
|
||||
instance_folder: "versions/1.12.2-pcl".to_string(),
|
||||
instance_path: None,
|
||||
game_dir_mode: None,
|
||||
},
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let files = sync_instance_content_files(&instance, &state)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.any(|file| file.relative_path == "mods/isolated-mod.jar"),
|
||||
"refresh must resolve the PCL-isolated gameDir, got: {:?}",
|
||||
files
|
||||
.iter()
|
||||
.map(|file| &file.relative_path)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ordinary_instance_refresh_still_scans_its_profile_directory() {
|
||||
let state = global_state().await;
|
||||
let metadata = crate::api::instance::create(
|
||||
format!("sync-normal {}", uuid::Uuid::new_v4()),
|
||||
"1.20.1".to_string(),
|
||||
crate::state::ModLoader::Vanilla,
|
||||
None,
|
||||
None,
|
||||
crate::state::InstanceLink::Unmanaged,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mods = state
|
||||
.directories
|
||||
.instances_dir()
|
||||
.join(&metadata.instance.path)
|
||||
.join("mods");
|
||||
fs::create_dir_all(&mods).unwrap();
|
||||
fs::write(mods.join("profile-mod.jar"), b"profile mod bytes").unwrap();
|
||||
|
||||
let files = sync_instance_content_files(&metadata.instance, &state)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.any(|file| file.relative_path == "mods/profile-mod.jar"),
|
||||
"ordinary instances must keep scanning their profile directory, \
|
||||
got: {:?}",
|
||||
files
|
||||
.iter()
|
||||
.map(|file| &file.relative_path)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression probe: an instance whose `game_dir_override` points to an
|
||||
/// external `.minecraft` root must have its content scanned from that root,
|
||||
/// not from the (empty) managed instance folder. This is the split-brain
|
||||
/// the content page empty-state used to hit.
|
||||
#[cfg(not(feature = "tauri"))]
|
||||
#[tokio::test]
|
||||
async fn content_scan_uses_game_dir_override() {
|
||||
crate::event::EventState::init().await.unwrap();
|
||||
let root = tempfile::tempdir().unwrap().keep();
|
||||
let state =
|
||||
crate::State::init_for_test(root.to_string_lossy().to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create an external .minecraft root with a mod, outside the managed
|
||||
// profiles dir.
|
||||
let mc_root = tempfile::tempdir().unwrap();
|
||||
let mods_dir = mc_root.path().join("mods");
|
||||
fs::create_dir_all(&mods_dir).unwrap();
|
||||
fs::write(mods_dir.join("my-mod.jar"), "mod").unwrap();
|
||||
|
||||
let created = crate::api::instance::create(
|
||||
"Override Instance".to_string(),
|
||||
"1.20.1".to_string(),
|
||||
crate::state::ModLoader::Vanilla,
|
||||
None,
|
||||
None,
|
||||
crate::state::InstanceLink::Unmanaged,
|
||||
None,
|
||||
Some(mc_root.path().to_string_lossy().to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
crate::state::instances::commands::set_instance_install_stage(
|
||||
&created.instance.id,
|
||||
crate::state::InstanceInstallStage::Installed,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let instance =
|
||||
crate::state::instances::adapters::sqlite::instance_rows::get_instance_by_id(
|
||||
&created.instance.id,
|
||||
&state.pool,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
state.directories.instance_game_dir(&instance),
|
||||
mc_root.path(),
|
||||
"instance_game_dir must resolve to the override root"
|
||||
);
|
||||
let direct =
|
||||
filesystem::scan_content_files_from(mc_root.path(), &instance.path)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
direct.len(),
|
||||
1,
|
||||
"direct scan of override root should find the mod"
|
||||
);
|
||||
let files = sync_instance_content_files(&instance, &state)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
files.len(),
|
||||
1,
|
||||
"expected the override-root mod to be scanned"
|
||||
);
|
||||
assert!(files[0].relative_path.ends_with("mods/my-mod.jar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_hash_or_size_change_invalidates_physical_identity() {
|
||||
let now = Utc::now();
|
||||
let file = InstanceFile {
|
||||
id: "file".to_string(),
|
||||
instance_id: "instance".to_string(),
|
||||
relative_path: "mods/lithium.jar".to_string(),
|
||||
file_name: "lithium.jar".to_string(),
|
||||
enabled: true,
|
||||
sha1: "official".to_string(),
|
||||
size: 10,
|
||||
missing: false,
|
||||
added_at: now,
|
||||
modified_at: now,
|
||||
local_mod_data: None,
|
||||
icon_path: None,
|
||||
};
|
||||
|
||||
assert!(!physical_file_identity_changed(&file, "official", 10));
|
||||
assert!(physical_file_identity_changed(&file, "external", 10));
|
||||
assert!(physical_file_identity_changed(&file, "official", 11));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
use super::create_direct_link_instance::create_direct_link_instance;
|
||||
use crate::api::pack::import::direct_link::{
|
||||
detect_direct_link_source, direct_link_group,
|
||||
has_minecraft_version_manifest, resolve_direct_link,
|
||||
};
|
||||
use crate::event::{InstancePayloadType, emit::emit_instance};
|
||||
use crate::launcher::ExternalGameDirMode;
|
||||
use crate::state::instances::{
|
||||
CreateDirectLinkInstance, EditInstance, adapters::sqlite::instance_rows,
|
||||
};
|
||||
use crate::state::{AppliedContentSetPatch, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExternalMinecraftRoot {
|
||||
pub path: PathBuf,
|
||||
#[serde(default = "default_external_root_mode")]
|
||||
pub mode: ExternalGameDirMode,
|
||||
}
|
||||
|
||||
fn default_external_root_mode() -> ExternalGameDirMode {
|
||||
// Existing string-only Settings entries used version isolation exclusively.
|
||||
ExternalGameDirMode::Isolated
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirectLinkSyncReport {
|
||||
pub imported: u32,
|
||||
pub updated: u32,
|
||||
pub removed: u32,
|
||||
pub missing: u32,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Reconciles configured external `.minecraft` roots with Axolotl's instance
|
||||
/// records. The external filesystem is authoritative: new version folders are
|
||||
/// associated, changed JSON metadata is refreshed, and records whose version
|
||||
/// JSON disappeared are removed without touching any remaining files.
|
||||
pub(crate) async fn sync_direct_link_instances(
|
||||
roots: Vec<ExternalMinecraftRoot>,
|
||||
state: &State,
|
||||
) -> crate::Result<DirectLinkSyncReport> {
|
||||
let mut report = DirectLinkSyncReport::default();
|
||||
let mut canonical_roots = Vec::new();
|
||||
for root in &roots {
|
||||
match crate::util::io::canonicalize(&root.path) {
|
||||
Ok(path) if path.is_dir() => {
|
||||
canonical_roots.push((path, root.mode))
|
||||
}
|
||||
Ok(_) => {
|
||||
report.missing += 1;
|
||||
report.errors.push(format!(
|
||||
"{} is not a directory",
|
||||
root.path.display()
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
report.missing += 1;
|
||||
}
|
||||
report
|
||||
.errors
|
||||
.push(format!("{}: {error}", root.path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
canonical_roots.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
canonical_roots.dedup_by(|left, right| left.0 == right.0);
|
||||
|
||||
let existing = crate::state::list_instances(&state.pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let mut seen_json = Vec::<PathBuf>::new();
|
||||
|
||||
for (root, mode) in &canonical_roots {
|
||||
let versions = root.join("versions");
|
||||
let entries = match std::fs::read_dir(&versions) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) => {
|
||||
report
|
||||
.errors
|
||||
.push(format!("{}: {error}", versions.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(error) => {
|
||||
report
|
||||
.errors
|
||||
.push(format!("{}: {error}", versions.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let folder = entry.path();
|
||||
if !folder.is_dir() {
|
||||
continue;
|
||||
}
|
||||
// A `versions` directory can also contain PCL bookkeeping entries
|
||||
// left by an interrupted install. They have no version manifest,
|
||||
// so they are not Minecraft instances and should not surface a
|
||||
// warning on every root synchronization.
|
||||
if !has_minecraft_version_manifest(&folder) {
|
||||
continue;
|
||||
}
|
||||
let folder_name = entry.file_name().to_string_lossy().to_string();
|
||||
let instance_folder = Path::new("versions").join(&folder_name);
|
||||
let source = detect_direct_link_source(root, &folder);
|
||||
let resolved = match resolve_direct_link(
|
||||
source.launcher,
|
||||
source.launcher_root.clone(),
|
||||
instance_folder.to_string_lossy().to_string(),
|
||||
Some(folder.to_string_lossy().to_string()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resolved) => resolved,
|
||||
Err(error) => {
|
||||
report
|
||||
.errors
|
||||
.push(format!("{}: {error}", folder.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
seen_json.push(resolved.version_json.clone());
|
||||
|
||||
let existing_instance = existing.iter().find(|metadata| {
|
||||
metadata
|
||||
.instance
|
||||
.linked_version_json_path
|
||||
.as_deref()
|
||||
.is_some_and(|path| {
|
||||
Path::new(path) == resolved.version_json
|
||||
})
|
||||
|| metadata
|
||||
.instance
|
||||
.game_dir_override
|
||||
.as_deref()
|
||||
.is_some_and(|path| Path::new(path) == folder)
|
||||
});
|
||||
|
||||
if let Some(metadata) = existing_instance {
|
||||
let instance = &metadata.instance;
|
||||
let fields_changed = instance.linked_version_id.as_deref()
|
||||
!= Some(resolved.version_id.as_str())
|
||||
|| instance.linked_launcher.as_deref()
|
||||
!= Some(resolved.launcher_key())
|
||||
|| instance.linked_launcher_root.as_deref()
|
||||
!= Some(
|
||||
resolved.launcher_root.to_string_lossy().as_ref(),
|
||||
)
|
||||
|| instance.linked_dot_minecraft.as_deref()
|
||||
!= Some(
|
||||
resolved.dot_minecraft.to_string_lossy().as_ref(),
|
||||
)
|
||||
|| instance.linked_game_dir_mode.as_deref()
|
||||
!= Some(mode.key());
|
||||
let content_changed = metadata.applied_content_set.game_version
|
||||
!= resolved.game_version
|
||||
|| metadata.applied_content_set.loader != resolved.loader;
|
||||
let groups = direct_link_group(&resolved.dot_minecraft)
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let groups_changed = metadata.groups != groups;
|
||||
if fields_changed || content_changed || groups_changed {
|
||||
if content_changed {
|
||||
crate::state::edit_instance(
|
||||
&instance.id,
|
||||
EditInstance {
|
||||
content_set_patch: Some(
|
||||
AppliedContentSetPatch {
|
||||
game_version: Some(
|
||||
resolved.game_version.clone(),
|
||||
),
|
||||
loader: Some(resolved.loader),
|
||||
..AppliedContentSetPatch::default()
|
||||
},
|
||||
),
|
||||
..EditInstance::default()
|
||||
},
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let mut tx = state.pool.begin().await?;
|
||||
instance_rows::set_direct_link_fields(
|
||||
&instance.id,
|
||||
&instance_rows::DirectLinkFields {
|
||||
launcher: Some(resolved.launcher_key().to_string()),
|
||||
launcher_root: Some(
|
||||
resolved
|
||||
.launcher_root
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
),
|
||||
dot_minecraft: Some(
|
||||
resolved
|
||||
.dot_minecraft
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
),
|
||||
version_id: Some(resolved.version_id.clone()),
|
||||
version_json_path: Some(
|
||||
resolved
|
||||
.version_json
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
),
|
||||
game_dir_mode: Some(mode.key().to_string()),
|
||||
},
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
instance_rows::replace_instance_groups(
|
||||
&instance.id,
|
||||
&groups,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
let _ = emit_instance(
|
||||
&instance.id,
|
||||
InstancePayloadType::Edited,
|
||||
)
|
||||
.await;
|
||||
report.updated += 1;
|
||||
}
|
||||
} else {
|
||||
let instance = match create_direct_link_instance(
|
||||
CreateDirectLinkInstance {
|
||||
name: Some(folder_name),
|
||||
launcher_type: source.launcher,
|
||||
base_path: source.launcher_root,
|
||||
instance_folder: instance_folder
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
instance_path: Some(
|
||||
folder.to_string_lossy().to_string(),
|
||||
),
|
||||
game_dir_mode: Some(*mode),
|
||||
},
|
||||
state,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(instance) => instance,
|
||||
Err(error) => {
|
||||
report
|
||||
.errors
|
||||
.push(format!("{}: {error}", folder.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let _ =
|
||||
emit_instance(&instance.id, InstancePayloadType::Created)
|
||||
.await;
|
||||
report.imported += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ordinary instances created with a version-isolated game-dir override
|
||||
// are also associated with a configured root. If that root is removed
|
||||
// from Settings before the next scan promotes the record to a direct
|
||||
// link, drop only the Axolotl record here as well.
|
||||
for metadata in &existing {
|
||||
if metadata.instance.linked_dot_minecraft.is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some(game_dir_override) =
|
||||
metadata.instance.game_dir_override.as_deref()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(root) = version_isolated_root(game_dir_override) else {
|
||||
continue;
|
||||
};
|
||||
if configured_root_matches(&root, &canonical_roots, &roots) {
|
||||
continue;
|
||||
}
|
||||
instance_rows::delete_instance_by_id(
|
||||
&metadata.instance.id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
let _ =
|
||||
emit_instance(&metadata.instance.id, InstancePayloadType::Removed)
|
||||
.await;
|
||||
report.removed += 1;
|
||||
}
|
||||
|
||||
for metadata in existing {
|
||||
let Some(json_path) =
|
||||
metadata.instance.linked_version_json_path.as_deref()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(root) = metadata.instance.linked_dot_minecraft.as_deref()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !configured_root_matches(Path::new(root), &canonical_roots, &roots) {
|
||||
// Configured roots are authoritative. Removing a root from Settings
|
||||
// only drops Axolotl's association; the external files remain intact.
|
||||
instance_rows::delete_instance_by_id(
|
||||
&metadata.instance.id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
let _ = emit_instance(
|
||||
&metadata.instance.id,
|
||||
InstancePayloadType::Removed,
|
||||
)
|
||||
.await;
|
||||
report.removed += 1;
|
||||
continue;
|
||||
}
|
||||
let json_path = PathBuf::from(json_path);
|
||||
if !json_path.exists()
|
||||
&& !seen_json.iter().any(|path| path == &json_path)
|
||||
{
|
||||
// External deletion is authoritative, but there is nothing left
|
||||
// to delete on disk. Only remove the stale Axolotl record.
|
||||
instance_rows::delete_instance_by_id(
|
||||
&metadata.instance.id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
let _ = emit_instance(
|
||||
&metadata.instance.id,
|
||||
InstancePayloadType::Removed,
|
||||
)
|
||||
.await;
|
||||
report.removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn version_isolated_root(path: &str) -> Option<PathBuf> {
|
||||
let version_dir = Path::new(path);
|
||||
if version_dir
|
||||
.parent()
|
||||
.and_then(Path::file_name)
|
||||
.and_then(|name| name.to_str())
|
||||
!= Some("versions")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
version_dir.parent()?.parent().map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// A root that remains in Settings must retain its associated records even
|
||||
/// when it cannot currently be opened (for example, a disconnected drive or
|
||||
/// a transient permission failure). Only removing the root from Settings may
|
||||
/// drop all of its associations.
|
||||
fn configured_root_matches(
|
||||
root: &Path,
|
||||
canonical_roots: &[(PathBuf, ExternalGameDirMode)],
|
||||
configured_roots: &[ExternalMinecraftRoot],
|
||||
) -> bool {
|
||||
canonical_roots
|
||||
.iter()
|
||||
.any(|(candidate, _)| candidate == root)
|
||||
|| configured_roots
|
||||
.iter()
|
||||
.any(|candidate| paths_match(&candidate.path, root))
|
||||
}
|
||||
|
||||
fn paths_match(left: &Path, right: &Path) -> bool {
|
||||
let left = left.components().collect::<PathBuf>();
|
||||
let right = right.components().collect::<PathBuf>();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
left.to_string_lossy()
|
||||
.eq_ignore_ascii_case(&right.to_string_lossy())
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
left == right
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn configured_but_unavailable_root_keeps_its_association() {
|
||||
let root = PathBuf::from("minecraft-root");
|
||||
let equivalent = PathBuf::from("minecraft-root").join(".");
|
||||
|
||||
assert!(configured_root_matches(
|
||||
&equivalent,
|
||||
&[],
|
||||
&[ExternalMinecraftRoot {
|
||||
path: root,
|
||||
mode: ExternalGameDirMode::Isolated,
|
||||
}],
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_root_does_not_keep_its_association() {
|
||||
assert!(!configured_root_matches(
|
||||
Path::new("minecraft-root"),
|
||||
&[],
|
||||
&[ExternalMinecraftRoot {
|
||||
path: PathBuf::from("other-root"),
|
||||
mode: ExternalGameDirMode::Isolated,
|
||||
}],
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
use crate::state::instances::{
|
||||
InstanceLaunchOverridesData, InstanceMetadata,
|
||||
adapters::sqlite::config_sync_rows, get_instance,
|
||||
};
|
||||
use crate::state::{DirectoryInfo, State};
|
||||
use crate::util::io;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashSet;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::Duration;
|
||||
|
||||
pub(crate) const CONFIG_FILE_NAME: &str = "axolotl_config.json";
|
||||
pub(crate) const CONFIG_FILE_TEMP_NAME: &str = "axolotl_config.json.tmp";
|
||||
|
||||
const CONFIG_SCHEMA_VERSION: u32 = 1;
|
||||
const DIRTY_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
const RECONCILE_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
static DIRTY_INSTANCES: LazyLock<DashSet<String>> = LazyLock::new(DashSet::new);
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct InstanceConfigFile {
|
||||
schema_version: u32,
|
||||
instance_id: String,
|
||||
path: String,
|
||||
generated_at: DateTime<Utc>,
|
||||
name: String,
|
||||
icon_path: Option<String>,
|
||||
update_channel: String,
|
||||
symlink_target: Option<String>,
|
||||
#[serde(default)]
|
||||
game_dir_override: Option<String>,
|
||||
groups: Vec<String>,
|
||||
content_set: InstanceConfigContentSet,
|
||||
link: crate::state::instances::InstanceLink,
|
||||
launch_overrides: InstanceLaunchOverridesData,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct InstanceConfigContentSet {
|
||||
source_kind: crate::state::instances::ContentSourceKind,
|
||||
game_version: String,
|
||||
protocol_version: Option<u32>,
|
||||
loader: crate::state::ModLoader,
|
||||
loader_version: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn mark_dirty(instance_id: impl Into<String>) {
|
||||
DIRTY_INSTANCES.insert(instance_id.into());
|
||||
}
|
||||
|
||||
pub(crate) async fn run(state: Arc<State>) {
|
||||
if let Err(error) = reconcile_all(&state).await {
|
||||
tracing::warn!("Failed to reconcile instance config files: {error}");
|
||||
}
|
||||
|
||||
let mut dirty_tick = tokio::time::interval(DIRTY_POLL_INTERVAL);
|
||||
dirty_tick.tick().await;
|
||||
let mut reconcile_tick = tokio::time::interval(RECONCILE_INTERVAL);
|
||||
reconcile_tick.tick().await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = dirty_tick.tick() => {
|
||||
let mut dirty = Vec::new();
|
||||
DIRTY_INSTANCES.retain(|instance_id| {
|
||||
dirty.push(instance_id.clone());
|
||||
false
|
||||
});
|
||||
|
||||
for instance_id in dirty {
|
||||
if let Err(error) = sync_instance(&state, &instance_id).await {
|
||||
tracing::warn!(
|
||||
"Failed to sync instance config for {instance_id}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = reconcile_tick.tick() => {
|
||||
if let Err(error) = reconcile_all(&state).await {
|
||||
tracing::warn!(
|
||||
"Failed to reconcile instance config files: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn sync_instance(
|
||||
state: &State,
|
||||
instance_id: &str,
|
||||
) -> crate::Result<()> {
|
||||
sync_instance_with_dirs(&state.directories, &state.pool, instance_id).await
|
||||
}
|
||||
|
||||
async fn sync_instance_with_dirs(
|
||||
dirs: &DirectoryInfo,
|
||||
pool: &SqlitePool,
|
||||
instance_id: &str,
|
||||
) -> crate::Result<()> {
|
||||
let Some(metadata) = get_instance(instance_id, pool).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Directly associated instances have no profile folder of their own and
|
||||
// must never write into the linked `.minecraft`; skip them entirely.
|
||||
if metadata.instance.linked_dot_minecraft.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config_path = config_path(dirs, &metadata.instance.path);
|
||||
let temp_path = config_path.with_file_name(CONFIG_FILE_TEMP_NAME);
|
||||
|
||||
match io::read(&config_path).await {
|
||||
Ok(existing) if config_matches(&metadata, &existing) => {
|
||||
config_sync_rows::update_config_sync_generated_at(
|
||||
instance_id,
|
||||
Utc::now().timestamp(),
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
|
||||
let bytes = serde_json::to_vec_pretty(&config_file(&metadata, Utc::now()))?;
|
||||
write_config_file(&config_path, &temp_path, &bytes).await?;
|
||||
config_sync_rows::update_config_sync_generated_at(
|
||||
instance_id,
|
||||
Utc::now().timestamp(),
|
||||
pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn reconcile_all(state: &State) -> crate::Result<()> {
|
||||
reconcile_all_with_dirs(&state.directories, &state.pool).await
|
||||
}
|
||||
|
||||
async fn reconcile_all_with_dirs(
|
||||
dirs: &DirectoryInfo,
|
||||
pool: &SqlitePool,
|
||||
) -> crate::Result<()> {
|
||||
let rows = config_sync_rows::list_instance_config_sync_rows(pool).await?;
|
||||
|
||||
for row in rows {
|
||||
let file_exists = tokio::fs::try_exists(config_path(dirs, &row.path))
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let needs_sync = !file_exists
|
||||
|| row.generated_at.is_none()
|
||||
|| row.config_updated_at.is_none()
|
||||
|| row.generated_at < row.config_updated_at;
|
||||
|
||||
if !needs_sync {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(error) =
|
||||
sync_instance_with_dirs(dirs, pool, &row.instance_id).await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to sync instance config for {}: {error}",
|
||||
row.instance_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_config_file(
|
||||
dirs: &DirectoryInfo,
|
||||
instance_path: &str,
|
||||
) -> crate::Result<()> {
|
||||
remove_if_exists(&config_path(dirs, instance_path)).await?;
|
||||
remove_if_exists(
|
||||
&dirs
|
||||
.instances_dir()
|
||||
.join(instance_path)
|
||||
.join(CONFIG_FILE_TEMP_NAME),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn config_path(
|
||||
dirs: &DirectoryInfo,
|
||||
instance_path: &str,
|
||||
) -> std::path::PathBuf {
|
||||
dirs.instances_dir()
|
||||
.join(instance_path)
|
||||
.join(CONFIG_FILE_NAME)
|
||||
}
|
||||
|
||||
fn config_file(
|
||||
metadata: &InstanceMetadata,
|
||||
generated_at: DateTime<Utc>,
|
||||
) -> InstanceConfigFile {
|
||||
let instance = &metadata.instance;
|
||||
|
||||
InstanceConfigFile {
|
||||
schema_version: CONFIG_SCHEMA_VERSION,
|
||||
instance_id: instance.id.clone(),
|
||||
path: instance.path.clone(),
|
||||
generated_at,
|
||||
name: instance.name.clone(),
|
||||
icon_path: instance.icon_path.clone(),
|
||||
update_channel: instance.update_channel.key().to_string(),
|
||||
symlink_target: instance.symlink_target.clone(),
|
||||
game_dir_override: instance.game_dir_override.clone(),
|
||||
groups: metadata.groups.clone(),
|
||||
content_set: InstanceConfigContentSet {
|
||||
source_kind: metadata.applied_content_set.source_kind,
|
||||
game_version: metadata.applied_content_set.game_version.clone(),
|
||||
protocol_version: metadata.applied_content_set.protocol_version,
|
||||
loader: metadata.applied_content_set.loader,
|
||||
loader_version: metadata.applied_content_set.loader_version.clone(),
|
||||
},
|
||||
link: metadata.link.clone(),
|
||||
launch_overrides: InstanceLaunchOverridesData::from(
|
||||
&metadata.launch_overrides,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn config_matches(metadata: &InstanceMetadata, existing: &[u8]) -> bool {
|
||||
let Ok(existing_config) =
|
||||
serde_json::from_slice::<InstanceConfigFile>(existing)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
serde_json::to_vec_pretty(&config_file(
|
||||
metadata,
|
||||
existing_config.generated_at,
|
||||
))
|
||||
.map(|expected| expected == existing)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn write_config_file(
|
||||
config_path: &Path,
|
||||
temp_path: &Path,
|
||||
bytes: &[u8],
|
||||
) -> crate::Result<()> {
|
||||
io::write(temp_path, bytes).await?;
|
||||
|
||||
let rename_result = io::retry_windows_sharing_violation(
|
||||
config_path,
|
||||
"renaming axolotl_config.json",
|
||||
|| tokio::fs::rename(temp_path, config_path),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(error) = rename_result {
|
||||
let _ = io::remove_file(temp_path).await;
|
||||
return Err(io::IOError::with_path(error, config_path).into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_if_exists(path: &Path) -> crate::Result<()> {
|
||||
match io::remove_file(path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::instances::{
|
||||
ContentSet, ContentSetStatus, ContentSourceKind, CreateInstance,
|
||||
EditInstance, Instance, InstanceLaunchOverrides,
|
||||
InstanceLaunchOverridesPatch, InstanceLink, create_instance,
|
||||
edit_instance,
|
||||
};
|
||||
use crate::state::{
|
||||
InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel,
|
||||
};
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("in-memory SQLite pool");
|
||||
sqlx::query("PRAGMA foreign_keys = ON")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("enable foreign keys");
|
||||
sqlx::migrate!().run(&pool).await.expect("migrations");
|
||||
pool
|
||||
}
|
||||
|
||||
fn test_dirs() -> (TempDir, DirectoryInfo) {
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
let dirs = DirectoryInfo {
|
||||
settings_dir: temp.path().to_path_buf(),
|
||||
config_dir: temp.path().to_path_buf(),
|
||||
app_identifier: "test".to_string(),
|
||||
};
|
||||
std::fs::create_dir_all(dirs.instances_dir()).expect("instances dir");
|
||||
(temp, dirs)
|
||||
}
|
||||
|
||||
async fn insert_instance(
|
||||
dirs: &DirectoryInfo,
|
||||
pool: &SqlitePool,
|
||||
instance_id: &str,
|
||||
instance_path: &str,
|
||||
name: &str,
|
||||
) {
|
||||
std::fs::create_dir_all(dirs.instances_dir().join(instance_path))
|
||||
.expect("instance dir");
|
||||
let now = Utc::now().timestamp();
|
||||
let content_set_id = format!("content-set:{instance_id}");
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO instances (
|
||||
id,
|
||||
path,
|
||||
applied_content_set_id,
|
||||
install_stage,
|
||||
launcher_feature_version,
|
||||
update_channel,
|
||||
name,
|
||||
icon_path,
|
||||
symlink_target,
|
||||
created,
|
||||
modified,
|
||||
last_played,
|
||||
pinned_at,
|
||||
submitted_time_played,
|
||||
recent_time_played
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, NULL, NULL, 0, 0)
|
||||
",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.bind(instance_path)
|
||||
.bind(&content_set_id)
|
||||
.bind("not_installed")
|
||||
.bind("migrated_launch_hooks")
|
||||
.bind("release")
|
||||
.bind(name)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert instance");
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO instance_content_sets (
|
||||
id,
|
||||
instance_id,
|
||||
name,
|
||||
source_kind,
|
||||
status,
|
||||
game_version,
|
||||
protocol_version,
|
||||
loader,
|
||||
loader_version,
|
||||
revision,
|
||||
created,
|
||||
modified
|
||||
)
|
||||
VALUES (?, ?, 'Default', 'local', 'available', '1.21.4',
|
||||
NULL, 'vanilla', NULL, 0, ?, ?)
|
||||
",
|
||||
)
|
||||
.bind(&content_set_id)
|
||||
.bind(instance_id)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert content set");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO instance_links (instance_id, link_kind)
|
||||
VALUES (?, 'unmanaged')",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert link");
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO instance_launch_overrides (instance_id, overrides)
|
||||
VALUES (?, jsonb('{}'))",
|
||||
)
|
||||
.bind(instance_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert launch overrides");
|
||||
}
|
||||
|
||||
fn sample_metadata() -> InstanceMetadata {
|
||||
let now = Utc::now();
|
||||
let instance = Instance {
|
||||
id: "local:serialization".to_string(),
|
||||
path: "Serialized Instance".to_string(),
|
||||
applied_content_set_id: Some("content-set:serialization".into()),
|
||||
install_stage: InstanceInstallStage::Installed,
|
||||
launcher_feature_version: LauncherFeatureVersion::MOST_RECENT,
|
||||
update_channel: ReleaseChannel::Beta,
|
||||
name: "Serialized Instance".to_string(),
|
||||
icon_path: Some(r"C:\absolute\icon.png".to_string()),
|
||||
symlink_target: Some(r"Z:\target".to_string()),
|
||||
linked_launcher: None,
|
||||
linked_launcher_root: None,
|
||||
linked_dot_minecraft: None,
|
||||
linked_version_id: None,
|
||||
linked_version_json_path: None,
|
||||
linked_game_dir_mode: None,
|
||||
game_dir_override: Some(r"D:\Games\.minecraft".to_string()),
|
||||
created: now,
|
||||
modified: now,
|
||||
last_played: Some(now),
|
||||
pinned_at: Some(now),
|
||||
submitted_time_played: 10,
|
||||
recent_time_played: 20,
|
||||
};
|
||||
let mut launch_overrides =
|
||||
InstanceLaunchOverrides::empty(instance.id.clone());
|
||||
launch_overrides.java_path = Some(r"C:\Java\bin\java.exe".to_string());
|
||||
|
||||
InstanceMetadata {
|
||||
instance,
|
||||
applied_content_set: ContentSet {
|
||||
id: "content-set:serialization".to_string(),
|
||||
instance_id: "local:serialization".to_string(),
|
||||
name: "Default".to_string(),
|
||||
source_kind: ContentSourceKind::ModrinthModpack,
|
||||
status: ContentSetStatus::Available,
|
||||
game_version: "1.21.4".to_string(),
|
||||
protocol_version: Some(767),
|
||||
loader: ModLoader::Fabric,
|
||||
loader_version: Some("0.16.9".to_string()),
|
||||
revision: 5,
|
||||
created: now,
|
||||
modified: now,
|
||||
},
|
||||
link: InstanceLink::ModrinthModpack {
|
||||
project_id: "project".to_string(),
|
||||
version_id: "version".to_string(),
|
||||
},
|
||||
groups: vec!["Group A".to_string()],
|
||||
launch_overrides,
|
||||
loader_components: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_config(
|
||||
dirs: &DirectoryInfo,
|
||||
instance_path: &str,
|
||||
) -> InstanceConfigFile {
|
||||
serde_json::from_slice(
|
||||
&std::fs::read(config_path(dirs, instance_path))
|
||||
.expect("read config"),
|
||||
)
|
||||
.expect("parse config")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialization_round_trip_preserves_metadata_and_paths() {
|
||||
let config = config_file(&sample_metadata(), Utc::now());
|
||||
let bytes = serde_json::to_vec_pretty(&config).unwrap();
|
||||
let roundtrip: InstanceConfigFile =
|
||||
serde_json::from_slice(&bytes).unwrap();
|
||||
|
||||
assert_eq!(roundtrip.schema_version, 1);
|
||||
assert_eq!(roundtrip.instance_id, "local:serialization");
|
||||
assert_eq!(roundtrip.path, "Serialized Instance");
|
||||
assert_eq!(roundtrip.name, "Serialized Instance");
|
||||
assert_eq!(roundtrip.update_channel, "beta");
|
||||
assert_eq!(
|
||||
roundtrip.icon_path.as_deref(),
|
||||
Some(r"C:\absolute\icon.png")
|
||||
);
|
||||
assert_eq!(roundtrip.symlink_target.as_deref(), Some(r"Z:\target"));
|
||||
assert_eq!(
|
||||
roundtrip.game_dir_override.as_deref(),
|
||||
Some(r"D:\Games\.minecraft")
|
||||
);
|
||||
assert_eq!(roundtrip.groups, vec!["Group A".to_string()]);
|
||||
assert_eq!(
|
||||
roundtrip.launch_overrides.java_path.as_deref(),
|
||||
Some(r"C:\Java\bin\java.exe")
|
||||
);
|
||||
match roundtrip.link {
|
||||
InstanceLink::ModrinthModpack {
|
||||
project_id,
|
||||
version_id,
|
||||
} => {
|
||||
assert_eq!(project_id, "project");
|
||||
assert_eq!(version_id, "version");
|
||||
}
|
||||
other => panic!("unexpected link: {other:?}"),
|
||||
}
|
||||
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert!(!text.contains("last_played"));
|
||||
assert!(!text.contains("pinned_at"));
|
||||
assert!(!text.contains("revision"));
|
||||
assert!(!text.contains("install_stage"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_instance_marks_state_and_writes_config_file() {
|
||||
let (temp, dirs) = test_dirs();
|
||||
let pool = test_pool().await;
|
||||
let state_dirs = DirectoryInfo {
|
||||
settings_dir: dirs.settings_dir.clone(),
|
||||
config_dir: dirs.config_dir.clone(),
|
||||
app_identifier: dirs.app_identifier.clone(),
|
||||
};
|
||||
let state = crate::state::test_state(state_dirs, pool.clone())
|
||||
.await
|
||||
.expect("test state");
|
||||
|
||||
let instance = create_instance(
|
||||
CreateInstance {
|
||||
name: "My Instance".to_string(),
|
||||
path: None,
|
||||
game_version: "1.21.4".to_string(),
|
||||
loader: ModLoader::Vanilla,
|
||||
loader_version: None,
|
||||
icon_path: None,
|
||||
link: InstanceLink::Unmanaged,
|
||||
symlink_target: None,
|
||||
game_dir_override: None,
|
||||
},
|
||||
&state,
|
||||
)
|
||||
.await
|
||||
.expect("create instance");
|
||||
|
||||
let state_rows: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM instance_config_sync_state
|
||||
WHERE instance_id = ?",
|
||||
)
|
||||
.bind(&instance.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(state_rows, 1);
|
||||
|
||||
sync_instance(&state, &instance.id)
|
||||
.await
|
||||
.expect("sync config");
|
||||
let config = read_config(&dirs, &instance.path);
|
||||
assert_eq!(config.instance_id, instance.id);
|
||||
assert_eq!(config.name, "My Instance");
|
||||
|
||||
drop(state);
|
||||
drop(temp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_instance_updates_name_and_java_path() {
|
||||
let (temp, dirs) = test_dirs();
|
||||
let pool = test_pool().await;
|
||||
insert_instance(
|
||||
&dirs,
|
||||
&pool,
|
||||
"local:edit",
|
||||
"Edit Instance",
|
||||
"Old Name",
|
||||
)
|
||||
.await;
|
||||
config_sync_rows::upsert_config_updated_at("local:edit", &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sync_instance_with_dirs(&dirs, &pool, "local:edit")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
edit_instance(
|
||||
"local:edit",
|
||||
EditInstance {
|
||||
name: Some("New Name".to_string()),
|
||||
launch_overrides: Some(InstanceLaunchOverridesPatch {
|
||||
java_path: Some(Some(r"C:\Java\java.exe".to_string())),
|
||||
..InstanceLaunchOverridesPatch::default()
|
||||
}),
|
||||
..EditInstance::default()
|
||||
},
|
||||
&pool,
|
||||
)
|
||||
.await
|
||||
.expect("edit instance");
|
||||
sync_instance_with_dirs(&dirs, &pool, "local:edit")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let config = read_config(&dirs, "Edit Instance");
|
||||
assert_eq!(config.name, "New Name");
|
||||
assert_eq!(
|
||||
config.launch_overrides.java_path.as_deref(),
|
||||
Some(r"C:\Java\java.exe")
|
||||
);
|
||||
|
||||
drop(temp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unchanged_content_does_not_rewrite_file() {
|
||||
let (temp, dirs) = test_dirs();
|
||||
let pool = test_pool().await;
|
||||
insert_instance(
|
||||
&dirs,
|
||||
&pool,
|
||||
"local:unchanged",
|
||||
"Unchanged Instance",
|
||||
"Name",
|
||||
)
|
||||
.await;
|
||||
config_sync_rows::upsert_config_updated_at("local:unchanged", &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sync_instance_with_dirs(&dirs, &pool, "local:unchanged")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let path = config_path(&dirs, "Unchanged Instance");
|
||||
let before_bytes = std::fs::read(&path).unwrap();
|
||||
let before_modified =
|
||||
std::fs::metadata(&path).unwrap().modified().unwrap();
|
||||
sqlx::query(
|
||||
"UPDATE instance_config_sync_state
|
||||
SET config_updated_at = generated_at + 100
|
||||
WHERE instance_id = ?",
|
||||
)
|
||||
.bind("local:unchanged")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
sync_instance_with_dirs(&dirs, &pool, "local:unchanged")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let after_bytes = std::fs::read(&path).unwrap();
|
||||
let after_modified =
|
||||
std::fs::metadata(&path).unwrap().modified().unwrap();
|
||||
assert_eq!(before_bytes, after_bytes);
|
||||
assert_eq!(before_modified, after_modified);
|
||||
|
||||
drop(temp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_all_rebuilds_missing_file() {
|
||||
let (temp, dirs) = test_dirs();
|
||||
let pool = test_pool().await;
|
||||
insert_instance(
|
||||
&dirs,
|
||||
&pool,
|
||||
"local:rebuild",
|
||||
"Rebuild Instance",
|
||||
"Name",
|
||||
)
|
||||
.await;
|
||||
config_sync_rows::upsert_config_updated_at("local:rebuild", &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
reconcile_all_with_dirs(&dirs, &pool).await.unwrap();
|
||||
|
||||
assert!(config_path(&dirs, "Rebuild Instance").is_file());
|
||||
let generated_at = config_sync_rows::get_config_sync_generated_at(
|
||||
"local:rebuild",
|
||||
&pool,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(generated_at.is_some());
|
||||
|
||||
drop(temp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconcile_all_skips_when_state_is_latest() {
|
||||
let (temp, dirs) = test_dirs();
|
||||
let pool = test_pool().await;
|
||||
insert_instance(
|
||||
&dirs,
|
||||
&pool,
|
||||
"local:latest",
|
||||
"Latest Instance",
|
||||
"Name",
|
||||
)
|
||||
.await;
|
||||
config_sync_rows::upsert_config_updated_at("local:latest", &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sync_instance_with_dirs(&dirs, &pool, "local:latest")
|
||||
.await
|
||||
.unwrap();
|
||||
let path = config_path(&dirs, "Latest Instance");
|
||||
let before_modified =
|
||||
std::fs::metadata(&path).unwrap().modified().unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
reconcile_all_with_dirs(&dirs, &pool).await.unwrap();
|
||||
|
||||
let after_modified =
|
||||
std::fs::metadata(&path).unwrap().modified().unwrap();
|
||||
assert_eq!(before_modified, after_modified);
|
||||
|
||||
drop(temp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrupt_json_is_rewritten() {
|
||||
let (temp, dirs) = test_dirs();
|
||||
let pool = test_pool().await;
|
||||
insert_instance(
|
||||
&dirs,
|
||||
&pool,
|
||||
"local:corrupt",
|
||||
"Corrupt Instance",
|
||||
"Name",
|
||||
)
|
||||
.await;
|
||||
config_sync_rows::upsert_config_updated_at("local:corrupt", &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
config_path(&dirs, "Corrupt Instance"),
|
||||
b"not valid json",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
sync_instance_with_dirs(&dirs, &pool, "local:corrupt")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
read_config(&dirs, "Corrupt Instance");
|
||||
|
||||
drop(temp);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deletion_cascades_state_and_removes_config_file() {
|
||||
let (temp, dirs) = test_dirs();
|
||||
let pool = test_pool().await;
|
||||
insert_instance(
|
||||
&dirs,
|
||||
&pool,
|
||||
"local:delete",
|
||||
"Delete Instance",
|
||||
"Name",
|
||||
)
|
||||
.await;
|
||||
config_sync_rows::upsert_config_updated_at("local:delete", &pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sync_instance_with_dirs(&dirs, &pool, "local:delete")
|
||||
.await
|
||||
.unwrap();
|
||||
let path = config_path(&dirs, "Delete Instance");
|
||||
let temp_path = path.with_file_name(CONFIG_FILE_TEMP_NAME);
|
||||
std::fs::write(&temp_path, b"tmp").unwrap();
|
||||
|
||||
remove_config_file(&dirs, "Delete Instance").await.unwrap();
|
||||
assert!(!path.exists());
|
||||
assert!(!temp_path.exists());
|
||||
|
||||
sqlx::query("DELETE FROM instances WHERE id = ?")
|
||||
.bind("local:delete")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let state_rows: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM instance_config_sync_state
|
||||
WHERE instance_id = ?",
|
||||
)
|
||||
.bind("local:delete")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(state_rows, 0);
|
||||
|
||||
drop(temp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use crate::state::{
|
||||
ContentItemUpdate, ContentProvider, ContentProviderRef, License, Project,
|
||||
ProjectType, Version, VersionEnvironment,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::ContentSourceKind;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ContentItem {
|
||||
pub file_name: String,
|
||||
pub file_path: String,
|
||||
pub id: String,
|
||||
pub size: u64,
|
||||
pub enabled: bool,
|
||||
pub project_type: ProjectType,
|
||||
pub project: Option<ContentItemProject>,
|
||||
pub version: Option<ContentItemVersion>,
|
||||
pub owner: Option<ContentItemOwner>,
|
||||
pub update: Option<ContentItemUpdate>,
|
||||
pub date_added: Option<String>,
|
||||
pub provider_refs: Vec<ContentProviderRef>,
|
||||
pub origin_provider: Option<ContentProvider>,
|
||||
/// Present when an update backup (`{active}_{previous}.old`) exists and
|
||||
/// can be rolled back; `file_name` is the file that would be restored.
|
||||
pub rollback: Option<ContentItemRollback>,
|
||||
/// Version-level environment (client/server/singleplayer) from the
|
||||
/// Modrinth v3 version API. `None` when the file has no Modrinth
|
||||
/// version match (e.g. CurseForge-only content).
|
||||
pub environment: Option<VersionEnvironment>,
|
||||
/// Local content source kind (local file, CurseForge pack member, ...).
|
||||
pub source_kind: Option<ContentSourceKind>,
|
||||
/// True when the file is not linked to any online project (no Modrinth
|
||||
/// hash match and no CurseForge reference).
|
||||
pub external: bool,
|
||||
/// Loader derived from the installed version's loaders when a Modrinth
|
||||
/// match exists, falling back to the locally parsed mod metadata.
|
||||
pub loader: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ContentItemRollback {
|
||||
pub file_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ContentItemProject {
|
||||
pub id: String,
|
||||
pub slug: Option<String>,
|
||||
pub title: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub license: Option<License>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ContentItemVersion {
|
||||
pub id: String,
|
||||
pub version_number: String,
|
||||
pub file_name: String,
|
||||
pub date_published: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ContentItemOwner {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub avatar_url: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub owner_type: OwnerType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OwnerType {
|
||||
User,
|
||||
Organization,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct LinkedModpackInfo {
|
||||
pub project: Project,
|
||||
pub version: Version,
|
||||
pub owner: Option<ContentItemOwner>,
|
||||
pub update: Option<ContentItemUpdate>,
|
||||
pub update_version: Option<Version>,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
pub(crate) mod config_sync;
|
||||
mod content;
|
||||
pub use self::content::*;
|
||||
|
||||
mod model;
|
||||
pub use self::model::*;
|
||||
|
||||
pub(crate) mod adapters;
|
||||
pub(crate) mod commands;
|
||||
pub(crate) use self::commands::get_content_snapshot;
|
||||
pub use self::commands::{
|
||||
AppliedContentSetPatch, CreateDirectLinkInstance, CreateInstance,
|
||||
DirectLinkSyncReport, EditInstance, ExternalMinecraftRoot,
|
||||
InstanceLaunchOverridesPatch, InstanceMetadata,
|
||||
};
|
||||
pub(crate) use self::commands::{
|
||||
create_direct_link_instance, create_instance, edit_instance, get_instance,
|
||||
get_instances_metadata, list_instances, refresh_all_instances,
|
||||
remove_instance, restore_instance_metadata, sync_direct_link_instances,
|
||||
};
|
||||
pub(crate) use self::commands::{
|
||||
dependencies_to_content_items, finalize_project_materialization,
|
||||
get_content_projects, get_installed_project_ids_for_instance,
|
||||
get_instance_install_candidates, get_linked_modpack_info,
|
||||
instance_content_root, list_content, list_content_by_paths,
|
||||
list_content_sets, list_linked_modpack_content,
|
||||
materialize_project_download, materialize_verified_project_download_copy,
|
||||
record_project_file_atomic, record_verified_curseforge_project_file_atomic,
|
||||
resolve_content_install_relative_path, restore_project_materialization,
|
||||
sync_content_files,
|
||||
};
|
||||
pub(crate) mod watcher;
|
||||
@@ -0,0 +1,70 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::state::ContentProvider;
|
||||
|
||||
use super::unknown_value;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentDependencyKind {
|
||||
Required,
|
||||
Include,
|
||||
}
|
||||
|
||||
impl ContentDependencyKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Required => "required",
|
||||
Self::Include => "include",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"required" => Ok(Self::Required),
|
||||
"include" => Ok(Self::Include),
|
||||
other => Err(unknown_value("content dependency kind", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ContentDependencyEdge {
|
||||
pub id: String,
|
||||
pub content_set_id: String,
|
||||
pub parent_entry_id: String,
|
||||
pub child_entry_id: String,
|
||||
/// Provider that supplied the dependency declaration or corroborating
|
||||
/// metadata. It is not assumed to own either endpoint.
|
||||
pub evidence_provider: ContentProvider,
|
||||
pub parent_provider: ContentProvider,
|
||||
pub child_provider: ContentProvider,
|
||||
pub dependency_kind: ContentDependencyKind,
|
||||
pub parent_project_id: String,
|
||||
pub parent_release_id: String,
|
||||
pub child_project_id: String,
|
||||
pub child_release_id: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Provider identity for one end of a persisted dependency edge.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentDependencyRef {
|
||||
pub provider: ContentProvider,
|
||||
pub project_id: String,
|
||||
pub release_id: String,
|
||||
}
|
||||
|
||||
/// Dependency state attached to a content snapshot item.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentDependencyInfo {
|
||||
pub auto_dependency: bool,
|
||||
pub required_by: Vec<ContentDependencyRef>,
|
||||
pub requires: Vec<ContentDependencyRef>,
|
||||
#[serde(default)]
|
||||
pub orphaned: bool,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use crate::state::ProjectType;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{ContentOwnershipKind, ContentSourceKind, unknown_value};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentRequirement {
|
||||
Required,
|
||||
Optional,
|
||||
Unsupported,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl ContentRequirement {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Required => "required",
|
||||
Self::Optional => "optional",
|
||||
Self::Unsupported => "unsupported",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"required" => Ok(Self::Required),
|
||||
"optional" => Ok(Self::Optional),
|
||||
"unsupported" => Ok(Self::Unsupported),
|
||||
"unknown" => Ok(Self::Unknown),
|
||||
other => Err(unknown_value("content requirement", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ContentEntry {
|
||||
pub id: String,
|
||||
pub instance_id: String,
|
||||
pub content_set_id: String,
|
||||
pub file_id: Option<String>,
|
||||
pub project_type: ProjectType,
|
||||
pub source_kind: ContentSourceKind,
|
||||
pub ownership_kind: ContentOwnershipKind,
|
||||
pub auto_dependency: bool,
|
||||
pub server_requirement: ContentRequirement,
|
||||
pub client_requirement: ContentRequirement,
|
||||
pub enabled: bool,
|
||||
pub added_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{ContentProvider, unknown_value};
|
||||
use crate::state::ProjectType;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentOwnershipKind {
|
||||
PackManaged,
|
||||
UserAdded,
|
||||
LocalDiscovered,
|
||||
}
|
||||
|
||||
impl Default for ContentOwnershipKind {
|
||||
fn default() -> Self {
|
||||
Self::UserAdded
|
||||
}
|
||||
}
|
||||
|
||||
impl ContentOwnershipKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::PackManaged => "pack_managed",
|
||||
Self::UserAdded => "user_added",
|
||||
Self::LocalDiscovered => "local_discovered",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"pack_managed" => Ok(Self::PackManaged),
|
||||
"user_added" => Ok(Self::UserAdded),
|
||||
"local_discovered" => Ok(Self::LocalDiscovered),
|
||||
other => Err(unknown_value("content ownership kind", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PackMemberMaterializationState {
|
||||
Present,
|
||||
PendingManual,
|
||||
Missing,
|
||||
Removed,
|
||||
}
|
||||
|
||||
impl PackMemberMaterializationState {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Present => "present",
|
||||
Self::PendingManual => "pending_manual",
|
||||
Self::Missing => "missing",
|
||||
Self::Removed => "removed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"present" => Ok(Self::Present),
|
||||
"pending_manual" => Ok(Self::PendingManual),
|
||||
"missing" => Ok(Self::Missing),
|
||||
"removed" => Ok(Self::Removed),
|
||||
other => {
|
||||
Err(unknown_value("pack member materialization state", other))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PackMemberOverrideKind {
|
||||
None,
|
||||
Disabled,
|
||||
Removed,
|
||||
Version,
|
||||
}
|
||||
|
||||
impl PackMemberOverrideKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Disabled => "disabled",
|
||||
Self::Removed => "removed",
|
||||
Self::Version => "version",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"none" => Ok(Self::None),
|
||||
"disabled" => Ok(Self::Disabled),
|
||||
"removed" => Ok(Self::Removed),
|
||||
"version" => Ok(Self::Version),
|
||||
other => Err(unknown_value("pack member override kind", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PackMember {
|
||||
pub id: String,
|
||||
pub content_set_id: String,
|
||||
pub content_entry_id: Option<String>,
|
||||
pub member_key: String,
|
||||
pub project_type: ProjectType,
|
||||
pub expected_relative_path: String,
|
||||
pub provider: Option<ContentProvider>,
|
||||
pub provider_project_id: Option<String>,
|
||||
pub provider_release_id: Option<String>,
|
||||
pub required: bool,
|
||||
pub expected_sha1: Option<String>,
|
||||
pub expected_size: Option<u64>,
|
||||
pub expected_fingerprint: Option<u64>,
|
||||
pub materialization_state: PackMemberMaterializationState,
|
||||
pub override_kind: PackMemberOverrideKind,
|
||||
pub reconciled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ManualDownloadOperationKind {
|
||||
PackInstall,
|
||||
PackUpdate,
|
||||
ContentInstall,
|
||||
ContentUpdate,
|
||||
}
|
||||
|
||||
impl Default for ManualDownloadOperationKind {
|
||||
fn default() -> Self {
|
||||
Self::ContentInstall
|
||||
}
|
||||
}
|
||||
|
||||
impl ManualDownloadOperationKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::PackInstall => "pack_install",
|
||||
Self::PackUpdate => "pack_update",
|
||||
Self::ContentInstall => "content_install",
|
||||
Self::ContentUpdate => "content_update",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"pack_install" => Ok(Self::PackInstall),
|
||||
"pack_update" => Ok(Self::PackUpdate),
|
||||
"content_install" => Ok(Self::ContentInstall),
|
||||
"content_update" => Ok(Self::ContentUpdate),
|
||||
other => {
|
||||
Err(unknown_value("manual download operation kind", other))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ManualDownloadState {
|
||||
Waiting,
|
||||
Matched,
|
||||
Imported,
|
||||
Error,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl ManualDownloadState {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Waiting => "waiting",
|
||||
Self::Matched => "matched",
|
||||
Self::Imported => "imported",
|
||||
Self::Error => "error",
|
||||
Self::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"waiting" => Ok(Self::Waiting),
|
||||
"matched" => Ok(Self::Matched),
|
||||
"imported" => Ok(Self::Imported),
|
||||
"error" => Ok(Self::Error),
|
||||
"cancelled" => Ok(Self::Cancelled),
|
||||
other => Err(unknown_value("manual download state", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PendingManualDownload {
|
||||
pub id: String,
|
||||
pub instance_id: String,
|
||||
pub pack_member_id: Option<String>,
|
||||
pub content_entry_id: Option<String>,
|
||||
pub operation_kind: ManualDownloadOperationKind,
|
||||
pub operation_target_id: Option<String>,
|
||||
pub project_type: ProjectType,
|
||||
pub provider: ContentProvider,
|
||||
pub provider_project_id: String,
|
||||
pub provider_release_id: String,
|
||||
pub file_name: String,
|
||||
pub website_url: Option<String>,
|
||||
pub target_relative_path: String,
|
||||
pub expected_sha1: Option<String>,
|
||||
pub expected_size: Option<u64>,
|
||||
pub expected_fingerprint: Option<u64>,
|
||||
pub state: ManualDownloadState,
|
||||
pub context: serde_json::Value,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pending_manual_download_serializes_camel_case_fields() {
|
||||
let now = Utc::now();
|
||||
let value = serde_json::to_value(PendingManualDownload {
|
||||
id: "manual-download:test".to_string(),
|
||||
instance_id: "instance:test".to_string(),
|
||||
pack_member_id: None,
|
||||
content_entry_id: None,
|
||||
operation_kind: ManualDownloadOperationKind::PackInstall,
|
||||
operation_target_id: None,
|
||||
project_type: ProjectType::Mod,
|
||||
provider: ContentProvider::CurseForge,
|
||||
provider_project_id: "123".to_string(),
|
||||
provider_release_id: "456".to_string(),
|
||||
file_name: "example.jar".to_string(),
|
||||
website_url: None,
|
||||
target_relative_path: "mods/example.jar".to_string(),
|
||||
expected_sha1: None,
|
||||
expected_size: None,
|
||||
expected_fingerprint: None,
|
||||
state: ManualDownloadState::Waiting,
|
||||
context: serde_json::Value::Null,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
})
|
||||
.expect("pending manual download should serialize");
|
||||
|
||||
assert_eq!(value["targetRelativePath"], "mods/example.jar");
|
||||
assert!(value.get("target_relative_path").is_none());
|
||||
assert_eq!(value["providerProjectId"], "123");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use super::unknown_value;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentProvider {
|
||||
Modrinth,
|
||||
#[serde(rename = "curseforge")]
|
||||
CurseForge,
|
||||
McArchive,
|
||||
/// Dependency edges between locally identified files, matched through
|
||||
/// embedded mod metadata instead of an online provider.
|
||||
Local,
|
||||
}
|
||||
|
||||
impl ContentProvider {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Modrinth => "modrinth",
|
||||
Self::CurseForge => "curseforge",
|
||||
Self::McArchive => "mcarchive",
|
||||
Self::Local => "local",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"modrinth" => Ok(Self::Modrinth),
|
||||
"curseforge" => Ok(Self::CurseForge),
|
||||
"mcarchive" => Ok(Self::McArchive),
|
||||
"local" => Ok(Self::Local),
|
||||
other => Err(unknown_value("content provider", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! modrinth_id {
|
||||
($name:ident, $kind:literal) => {
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
Eq,
|
||||
Hash,
|
||||
Ord,
|
||||
PartialEq,
|
||||
PartialOrd,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct $name(String);
|
||||
|
||||
impl $name {
|
||||
pub fn new(value: impl Into<String>) -> crate::Result<Self> {
|
||||
let value = value.into();
|
||||
if value.trim().is_empty() {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Empty {}",
|
||||
$kind
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for $name {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(formatter)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
modrinth_id!(ModrinthProjectId, "Modrinth project ID");
|
||||
modrinth_id!(ModrinthVersionId, "Modrinth version ID");
|
||||
modrinth_id!(McArchiveProjectId, "MCArchive project ID");
|
||||
modrinth_id!(McArchiveVersionId, "MCArchive version ID");
|
||||
modrinth_id!(McArchiveFileId, "MCArchive file ID");
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Eq,
|
||||
Hash,
|
||||
Ord,
|
||||
PartialEq,
|
||||
PartialOrd,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct CurseForgeProjectId(u32);
|
||||
|
||||
impl CurseForgeProjectId {
|
||||
pub fn new(value: u32) -> crate::Result<Self> {
|
||||
if value == 0 {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Invalid CurseForge project ID 0".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn get(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Eq,
|
||||
Hash,
|
||||
Ord,
|
||||
PartialEq,
|
||||
PartialOrd,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct CurseForgeFileId(u32);
|
||||
|
||||
impl CurseForgeFileId {
|
||||
pub fn new(value: u32) -> crate::Result<Self> {
|
||||
if value == 0 {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Invalid CurseForge file ID 0".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn get(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "provider", rename_all = "lowercase")]
|
||||
pub enum ContentProviderRef {
|
||||
Modrinth {
|
||||
project_id: ModrinthProjectId,
|
||||
version_id: Option<ModrinthVersionId>,
|
||||
},
|
||||
CurseForge {
|
||||
project_id: CurseForgeProjectId,
|
||||
file_id: Option<CurseForgeFileId>,
|
||||
},
|
||||
McArchive {
|
||||
project_id: McArchiveProjectId,
|
||||
version_id: Option<McArchiveVersionId>,
|
||||
file_id: Option<McArchiveFileId>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ContentProviderRef {
|
||||
pub fn provider(&self) -> ContentProvider {
|
||||
match self {
|
||||
Self::Modrinth { .. } => ContentProvider::Modrinth,
|
||||
Self::CurseForge { .. } => ContentProvider::CurseForge,
|
||||
Self::McArchive { .. } => ContentProvider::McArchive,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_database(
|
||||
provider: &str,
|
||||
project_id: &str,
|
||||
version_id: Option<&str>,
|
||||
file_id: Option<&str>,
|
||||
) -> crate::Result<Self> {
|
||||
match ContentProvider::from_str(provider)? {
|
||||
ContentProvider::Modrinth => Ok(Self::Modrinth {
|
||||
project_id: ModrinthProjectId::new(project_id)?,
|
||||
version_id: version_id
|
||||
.map(ModrinthVersionId::new)
|
||||
.transpose()?,
|
||||
}),
|
||||
ContentProvider::CurseForge => Ok(Self::CurseForge {
|
||||
project_id: CurseForgeProjectId::new(
|
||||
project_id.parse().map_err(|_| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Invalid CurseForge project ID {project_id}"
|
||||
))
|
||||
})?,
|
||||
)?,
|
||||
file_id: match file_id.or(version_id) {
|
||||
Some(value) => Some(CurseForgeFileId::new(
|
||||
value.parse().map_err(|_| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Invalid CurseForge file ID {value}"
|
||||
))
|
||||
})?,
|
||||
)?),
|
||||
None => None,
|
||||
},
|
||||
}),
|
||||
ContentProvider::McArchive => Ok(Self::McArchive {
|
||||
project_id: McArchiveProjectId::new(project_id)?,
|
||||
version_id: version_id
|
||||
.map(McArchiveVersionId::new)
|
||||
.transpose()?,
|
||||
file_id: file_id.map(McArchiveFileId::new).transpose()?,
|
||||
}),
|
||||
ContentProvider::Local => Err(crate::ErrorKind::InputError(
|
||||
"Local provider references only exist on dependency edges"
|
||||
.to_string(),
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn database_project_id(&self) -> String {
|
||||
match self {
|
||||
Self::Modrinth { project_id, .. } => project_id.to_string(),
|
||||
Self::CurseForge { project_id, .. } => project_id.get().to_string(),
|
||||
Self::McArchive { project_id, .. } => project_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn database_version_id(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Modrinth { version_id, .. } => {
|
||||
version_id.as_ref().map(ToString::to_string)
|
||||
}
|
||||
Self::CurseForge { .. } => None,
|
||||
Self::McArchive { version_id, .. } => {
|
||||
version_id.as_ref().map(ToString::to_string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn database_file_id(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Modrinth { .. } => None,
|
||||
Self::CurseForge { file_id, .. } => {
|
||||
file_id.map(|value| value.get().to_string())
|
||||
}
|
||||
Self::McArchive { file_id, .. } => {
|
||||
file_id.as_ref().map(ToString::to_string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn database_release_id(&self) -> Option<String> {
|
||||
self.database_version_id()
|
||||
.or_else(|| self.database_file_id())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "provider", rename_all = "lowercase")]
|
||||
pub enum ContentItemUpdate {
|
||||
Modrinth {
|
||||
project_id: ModrinthProjectId,
|
||||
current_version_id: ModrinthVersionId,
|
||||
target_version_id: ModrinthVersionId,
|
||||
},
|
||||
CurseForge {
|
||||
project_id: CurseForgeProjectId,
|
||||
current_file_id: CurseForgeFileId,
|
||||
target_file_id: CurseForgeFileId,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn provider_ids_cannot_be_cross_parsed() {
|
||||
let curseforge_project = CurseForgeProjectId::new(42).unwrap();
|
||||
let curseforge_file = CurseForgeFileId::new(7).unwrap();
|
||||
assert!(
|
||||
ModrinthProjectId::new(curseforge_project.get().to_string())
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
ModrinthVersionId::new(curseforge_file.get().to_string()).is_ok()
|
||||
);
|
||||
|
||||
let reference = ContentProviderRef::CurseForge {
|
||||
project_id: curseforge_project,
|
||||
file_id: Some(curseforge_file),
|
||||
};
|
||||
assert!(matches!(reference, ContentProviderRef::CurseForge { .. }));
|
||||
assert!(!matches!(reference, ContentProviderRef::Modrinth { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_database_references_are_rejected() {
|
||||
assert!(
|
||||
ContentProviderRef::from_database(
|
||||
"curseforge",
|
||||
"not-a-number",
|
||||
Some("7"),
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
ContentProviderRef::from_database("unknown", "project", None, None,)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcarchive_version_and_file_identifiers_round_trip() {
|
||||
let reference = ContentProviderRef::McArchive {
|
||||
project_id: McArchiveProjectId::new("project-uuid").unwrap(),
|
||||
version_id: Some(McArchiveVersionId::new("version-uuid").unwrap()),
|
||||
file_id: Some(McArchiveFileId::new("file-uuid").unwrap()),
|
||||
};
|
||||
let restored = ContentProviderRef::from_database(
|
||||
"mcarchive",
|
||||
&reference.database_project_id(),
|
||||
reference.database_version_id().as_deref(),
|
||||
reference.database_file_id().as_deref(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(restored, reference);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_numeric_id_keeps_provider_identity() {
|
||||
let modrinth = ContentProviderRef::Modrinth {
|
||||
project_id: ModrinthProjectId::new("42").unwrap(),
|
||||
version_id: Some(ModrinthVersionId::new("7").unwrap()),
|
||||
};
|
||||
let curseforge = ContentProviderRef::CurseForge {
|
||||
project_id: CurseForgeProjectId::new(42).unwrap(),
|
||||
file_id: Some(CurseForgeFileId::new(7).unwrap()),
|
||||
};
|
||||
assert_ne!(modrinth, curseforge);
|
||||
assert_ne!(modrinth.provider(), curseforge.provider());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use crate::state::ModLoader;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::unknown_value;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentSourceKind {
|
||||
Local,
|
||||
CurseForge,
|
||||
McArchive,
|
||||
ModrinthModpack,
|
||||
ServerProject,
|
||||
ImportedModpack,
|
||||
SharedInstance,
|
||||
}
|
||||
|
||||
impl ContentSourceKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Local => "local",
|
||||
Self::CurseForge => "curseforge",
|
||||
Self::McArchive => "mcarchive",
|
||||
Self::ModrinthModpack => "modrinth_modpack",
|
||||
Self::ServerProject => "server_project",
|
||||
Self::ImportedModpack => "imported_modpack",
|
||||
Self::SharedInstance => "shared_instance",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"local" => Ok(Self::Local),
|
||||
"curseforge" => Ok(Self::CurseForge),
|
||||
"mcarchive" => Ok(Self::McArchive),
|
||||
"modrinth_modpack" => Ok(Self::ModrinthModpack),
|
||||
"server_project" => Ok(Self::ServerProject),
|
||||
"imported_modpack" => Ok(Self::ImportedModpack),
|
||||
"shared_instance" => Ok(Self::SharedInstance),
|
||||
other => Err(unknown_value("content source kind", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentSetStatus {
|
||||
Available,
|
||||
Installing,
|
||||
Stale,
|
||||
MissingFiles,
|
||||
}
|
||||
|
||||
impl ContentSetStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Available => "available",
|
||||
Self::Installing => "installing",
|
||||
Self::Stale => "stale",
|
||||
Self::MissingFiles => "missing_files",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"available" => Ok(Self::Available),
|
||||
"installing" => Ok(Self::Installing),
|
||||
"stale" => Ok(Self::Stale),
|
||||
"missing_files" => Ok(Self::MissingFiles),
|
||||
other => Err(unknown_value("content set status", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a playable setup slot for an instance.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ContentSet {
|
||||
pub id: String,
|
||||
pub instance_id: String,
|
||||
pub name: String,
|
||||
pub source_kind: ContentSourceKind,
|
||||
pub status: ContentSetStatus,
|
||||
pub game_version: String,
|
||||
pub protocol_version: Option<u32>,
|
||||
pub loader: ModLoader,
|
||||
pub loader_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub revision: u64,
|
||||
pub created: DateTime<Utc>,
|
||||
pub modified: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::unknown_value;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentSetRemoteRefType {
|
||||
SharedContentSet,
|
||||
HostingInstance,
|
||||
}
|
||||
|
||||
impl ContentSetRemoteRefType {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::SharedContentSet => "shared_content_set",
|
||||
Self::HostingInstance => "hosting_instance",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"shared_content_set" => Ok(Self::SharedContentSet),
|
||||
"hosting_instance" => Ok(Self::HostingInstance),
|
||||
other => Err(unknown_value("content set remote ref type", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ContentSetRemoteRef {
|
||||
pub content_set_id: String,
|
||||
pub ref_type: ContentSetRemoteRefType,
|
||||
pub ref_id: String,
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::unknown_value;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentSetSyncProvider {
|
||||
SharedInstance,
|
||||
}
|
||||
|
||||
impl ContentSetSyncProvider {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::SharedInstance => "shared_instance",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"shared_instance" => Ok(Self::SharedInstance),
|
||||
other => Err(unknown_value("content set sync provider", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentSetSyncStatus {
|
||||
Unknown,
|
||||
UpToDate,
|
||||
UpdateAvailable,
|
||||
Applying,
|
||||
Stale,
|
||||
NotReady,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl ContentSetSyncStatus {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::UpToDate => "up_to_date",
|
||||
Self::UpdateAvailable => "update_available",
|
||||
Self::Applying => "applying",
|
||||
Self::Stale => "stale",
|
||||
Self::NotReady => "not_ready",
|
||||
Self::Error => "error",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"unknown" => Ok(Self::Unknown),
|
||||
"up_to_date" => Ok(Self::UpToDate),
|
||||
"update_available" => Ok(Self::UpdateAvailable),
|
||||
"applying" => Ok(Self::Applying),
|
||||
"stale" => Ok(Self::Stale),
|
||||
"not_ready" => Ok(Self::NotReady),
|
||||
"error" => Ok(Self::Error),
|
||||
other => Err(unknown_value("content set sync status", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ContentSetSyncState {
|
||||
pub content_set_id: String,
|
||||
pub provider: ContentSetSyncProvider,
|
||||
pub applied_update_id: Option<String>,
|
||||
pub latest_available_update_id: Option<String>,
|
||||
pub checked_at: Option<DateTime<Utc>>,
|
||||
pub status: ContentSetSyncStatus,
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
ContentDependencyInfo, ContentOwnershipKind,
|
||||
PackMemberMaterializationState, PackMemberOverrideKind,
|
||||
PendingManualDownload,
|
||||
};
|
||||
use crate::state::{
|
||||
ContentItem, ContentProvider, LinkedModpackInfo, ProjectType,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentItemCapabilities {
|
||||
pub can_toggle: bool,
|
||||
pub can_delete: bool,
|
||||
pub can_update: bool,
|
||||
pub can_change_version: bool,
|
||||
pub can_restore_pack_default: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceContentSnapshotItem {
|
||||
pub file_id: Option<String>,
|
||||
pub entry_id: Option<String>,
|
||||
pub member_id: Option<String>,
|
||||
pub ownership_kind: ContentOwnershipKind,
|
||||
pub materialization_state: PackMemberMaterializationState,
|
||||
pub override_kind: PackMemberOverrideKind,
|
||||
pub expected_relative_path: String,
|
||||
pub required: bool,
|
||||
pub project_type: ProjectType,
|
||||
pub provider: Option<ContentProvider>,
|
||||
pub provider_project_id: Option<String>,
|
||||
pub provider_release_id: Option<String>,
|
||||
pub content: Option<ContentItem>,
|
||||
pub capabilities: ContentItemCapabilities,
|
||||
pub dependency: Option<ContentDependencyInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceContentPack {
|
||||
pub name: String,
|
||||
pub icon_path: Option<String>,
|
||||
pub provider: Option<ContentProvider>,
|
||||
pub project_id: Option<String>,
|
||||
pub version_id: Option<String>,
|
||||
pub reconciled: bool,
|
||||
pub can_update: bool,
|
||||
pub metadata: Option<LinkedModpackInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceContentWarning {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub provider: Option<ContentProvider>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceContentSnapshot {
|
||||
pub instance_id: String,
|
||||
pub revision: u64,
|
||||
pub pack: Option<InstanceContentPack>,
|
||||
pub items: Vec<InstanceContentSnapshotItem>,
|
||||
pub pending_manual_downloads: Vec<PendingManualDownload>,
|
||||
pub warnings: Vec<InstanceContentWarning>,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::ContentOwnershipKind;
|
||||
use crate::state::ContentProvider;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentUpdateScope {
|
||||
UserAdded,
|
||||
Pack,
|
||||
Item,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentUpdatePlanAction {
|
||||
pub content_id: String,
|
||||
pub relative_path: Option<String>,
|
||||
pub ownership_kind: ContentOwnershipKind,
|
||||
pub provider: ContentProvider,
|
||||
pub current_release_id: Option<String>,
|
||||
pub target_release_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentUpdatePlan {
|
||||
pub id: String,
|
||||
pub instance_id: String,
|
||||
pub revision: u64,
|
||||
pub scope: ContentUpdateScope,
|
||||
pub actions: Vec<ContentUpdatePlanAction>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentUpdateResolutionChoice {
|
||||
KeepOverride,
|
||||
RestorePackDefault,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentUpdateResolution {
|
||||
pub content_id: String,
|
||||
pub choice: ContentUpdateResolutionChoice,
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CoreComponentKind {
|
||||
JarMod,
|
||||
ReplacementJar,
|
||||
Agent,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoreComponentSource {
|
||||
pub provider: String,
|
||||
pub project_id: Option<String>,
|
||||
pub version_id: Option<String>,
|
||||
pub file_id: Option<String>,
|
||||
pub page_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoreComponent {
|
||||
pub id: String,
|
||||
pub kind: CoreComponentKind,
|
||||
pub file_name: String,
|
||||
pub relative_path: String,
|
||||
pub enabled: bool,
|
||||
pub removed: bool,
|
||||
pub order: i32,
|
||||
pub sha1: Option<String>,
|
||||
pub sha256: Option<String>,
|
||||
pub source: Option<CoreComponentSource>,
|
||||
pub target_game_version: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
pub failure_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CoreJarPreview {
|
||||
pub output_path: String,
|
||||
pub component_count: usize,
|
||||
pub replacement_component_id: Option<String>,
|
||||
pub entries: usize,
|
||||
pub sha1: String,
|
||||
pub sha256: String,
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::state::{ContentProvider, ContentProviderRef};
|
||||
|
||||
use super::ContentDependencyKind;
|
||||
|
||||
/// A provider-neutral, immutable selection of content dependencies. The
|
||||
/// provider-specific resolver records the exact release to download here so a
|
||||
/// preview and its subsequent install cannot independently select different
|
||||
/// files.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DependencyResolutionPlan {
|
||||
pub id: String,
|
||||
pub instance_id: String,
|
||||
pub instance_revision: Option<u64>,
|
||||
pub target: DependencyResolutionTarget,
|
||||
pub primary: ContentProviderRef,
|
||||
#[serde(default)]
|
||||
pub primary_expected_sha1: Option<String>,
|
||||
#[serde(default)]
|
||||
pub primary_expected_size: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub nodes: Vec<DependencyResolutionNode>,
|
||||
#[serde(default)]
|
||||
pub edges: Vec<DependencyResolutionEdge>,
|
||||
#[serde(default)]
|
||||
pub issues: Vec<DependencyResolutionIssue>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DependencyResolutionTarget {
|
||||
pub minecraft_version: Option<String>,
|
||||
pub loader: Option<String>,
|
||||
pub content_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DependencyResolutionNode {
|
||||
pub content: ContentProviderRef,
|
||||
pub parent: Option<ContentProviderRef>,
|
||||
pub relation: ContentDependencyKind,
|
||||
pub source: ContentProvider,
|
||||
pub selection_reason: DependencySelectionReason,
|
||||
#[serde(default)]
|
||||
pub expected_sha1: Option<String>,
|
||||
#[serde(default)]
|
||||
pub expected_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DependencyResolutionEdge {
|
||||
pub parent: ContentProviderRef,
|
||||
pub child: ContentProviderRef,
|
||||
pub relation: ContentDependencyKind,
|
||||
pub evidence_provider: ContentProvider,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DependencySelectionReason {
|
||||
ExactVersionId,
|
||||
NativeStrictMatch,
|
||||
Sha1VerifiedModrinthFallback,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DependencyResolutionIssue {
|
||||
pub provider: ContentProvider,
|
||||
pub project_id: String,
|
||||
pub parent: Option<ContentProviderRef>,
|
||||
pub relation: Option<ContentDependencyKind>,
|
||||
pub reason: String,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceFile {
|
||||
pub id: String,
|
||||
pub instance_id: String,
|
||||
pub relative_path: String,
|
||||
pub file_name: String,
|
||||
pub enabled: bool,
|
||||
pub sha1: String,
|
||||
pub size: u64,
|
||||
pub missing: bool,
|
||||
pub added_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
/// JSON-encoded `LocalModMetadata` extracted from the JAR's embedded
|
||||
/// mod metadata file (fabric.mod.json, quilt.mod.json, mods.toml, etc.).
|
||||
/// Populated when Modrinth hash lookup provides no match.
|
||||
pub local_mod_data: Option<String>,
|
||||
/// Absolute path of the cached extracted icon for unmatched content
|
||||
/// files. An empty string marks a file that was checked but has no icon.
|
||||
pub icon_path: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use crate::state::ModLoader;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct InstanceInstallTarget {
|
||||
pub game_version: String,
|
||||
pub loader: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct InstanceInstallCandidate {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub icon_path: Option<String>,
|
||||
pub game_version: String,
|
||||
pub loader: ModLoader,
|
||||
pub installed: bool,
|
||||
pub compatible: bool,
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use crate::state::{
|
||||
InstanceInstallStage, LauncherFeatureVersion, ReleaseChannel,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Instance {
|
||||
pub id: String,
|
||||
pub path: String,
|
||||
pub applied_content_set_id: Option<String>,
|
||||
pub install_stage: InstanceInstallStage,
|
||||
pub launcher_feature_version: LauncherFeatureVersion,
|
||||
pub update_channel: ReleaseChannel,
|
||||
pub name: String,
|
||||
pub icon_path: Option<String>,
|
||||
pub symlink_target: Option<String>,
|
||||
/// For "directly associated" instances: which external launcher manages
|
||||
/// the linked `.minecraft` (`hmcl`, `pcl2`, `pcl2_ce`, `generic`).
|
||||
#[serde(default)]
|
||||
pub linked_launcher: Option<String>,
|
||||
/// Canonical root selected for the external launcher import scan.
|
||||
#[serde(default)]
|
||||
pub linked_launcher_root: Option<String>,
|
||||
/// Absolute path of the linked `.minecraft` root the instance launches
|
||||
/// from; files are used in place, never copied or written to.
|
||||
#[serde(default)]
|
||||
pub linked_dot_minecraft: Option<String>,
|
||||
/// The actual version JSON stem used as the external version ID.
|
||||
#[serde(default)]
|
||||
pub linked_version_id: Option<String>,
|
||||
/// Canonical path of the actual local version JSON selected at creation.
|
||||
#[serde(default)]
|
||||
pub linked_version_json_path: Option<String>,
|
||||
/// Explicit game-directory layout selected for this external root. `None`
|
||||
/// preserves the launcher-specific behavior used by links created before
|
||||
/// external-root modes were configurable.
|
||||
#[serde(default)]
|
||||
pub linked_game_dir_mode: Option<String>,
|
||||
/// Optional absolute game-directory override for ordinary instances;
|
||||
/// directly associated instances resolve their game dir from the link
|
||||
/// metadata instead.
|
||||
#[serde(default)]
|
||||
pub game_dir_override: Option<String>,
|
||||
pub created: DateTime<Utc>,
|
||||
pub modified: DateTime<Utc>,
|
||||
pub last_played: Option<DateTime<Utc>>,
|
||||
pub pinned_at: Option<DateTime<Utc>>,
|
||||
pub submitted_time_played: u64,
|
||||
pub recent_time_played: u64,
|
||||
}
|
||||
|
||||
impl Instance {
|
||||
/// Whether this instance is "directly associated" with an external
|
||||
/// launcher (HMCL/PCL): it has no profile directory of its own and its
|
||||
/// files live inside the linked `.minecraft`.
|
||||
pub fn is_direct_linked(&self) -> bool {
|
||||
self.linked_dot_minecraft
|
||||
.as_deref()
|
||||
.is_some_and(|linked| !linked.trim().is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DailyPlaytime {
|
||||
pub date: String,
|
||||
pub played_seconds: u64,
|
||||
pub session_count: u64,
|
||||
pub top_instance_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DailyPlaytimeEntry {
|
||||
pub instance_id: String,
|
||||
pub instance_name: String,
|
||||
pub played_seconds: u64,
|
||||
pub session_count: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn playtime_to_storage(
|
||||
value: u64,
|
||||
column: &str,
|
||||
) -> crate::Result<i64> {
|
||||
i64::try_from(value).map_err(|_| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Expected {column} to fit in SQLite INTEGER"
|
||||
))
|
||||
.into()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::state::{ContentProvider, ModLoader, ProjectType};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ShaderRuntime {
|
||||
Iris,
|
||||
OptiFine,
|
||||
None,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceUpgradeEnvironment {
|
||||
pub game_version: String,
|
||||
pub mod_loader: ModLoader,
|
||||
pub mod_loader_version: Option<String>,
|
||||
pub shader_runtime: ShaderRuntime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstanceUpgradeItemStatus {
|
||||
UpgradeAvailable,
|
||||
AlreadyCompatible,
|
||||
NoCompatibleRelease,
|
||||
PrereleaseOnly,
|
||||
Unidentified,
|
||||
DependencyConflict,
|
||||
MissingRequiredDependency,
|
||||
IncompatibleDependency,
|
||||
UnsupportedContentType,
|
||||
NoCompatibleShaderRuntime,
|
||||
ShaderRuntimeMissing,
|
||||
ShaderRuntimeUnknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstanceUpgradeAction {
|
||||
Upgrade,
|
||||
Keep,
|
||||
Disable,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceUpgradeResolution {
|
||||
pub content_id: String,
|
||||
pub action: InstanceUpgradeAction,
|
||||
#[serde(default)]
|
||||
pub allow_prerelease: bool,
|
||||
#[serde(default)]
|
||||
pub confirmed_prerelease_dependencies:
|
||||
Vec<InstanceUpgradePrereleaseConfirmation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceUpgradeResolutionResult {
|
||||
pub content_id: String,
|
||||
pub code: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceUpgradeResolutionBatchResult {
|
||||
pub plan: InstanceUpgradePlan,
|
||||
pub requested_count: usize,
|
||||
pub applied: Vec<InstanceUpgradeResolutionResult>,
|
||||
pub skipped: Vec<InstanceUpgradeResolutionResult>,
|
||||
pub failed: Vec<InstanceUpgradeResolutionResult>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceUpgradePrereleaseConfirmation {
|
||||
pub provider: ContentProvider,
|
||||
pub project_id: String,
|
||||
pub version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceUpgradeItem {
|
||||
pub content_id: String,
|
||||
pub relative_path: String,
|
||||
pub project_type: ProjectType,
|
||||
pub provider: Option<ContentProvider>,
|
||||
pub project_id: Option<String>,
|
||||
pub current_release_id: Option<String>,
|
||||
pub current_enabled: bool,
|
||||
pub auto_dependency: bool,
|
||||
pub status: InstanceUpgradeItemStatus,
|
||||
pub resolution: InstanceUpgradeResolution,
|
||||
pub candidate_release_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstanceUpgradeIssueCode {
|
||||
PrereleaseOnly,
|
||||
Unidentified,
|
||||
DependencyConflict,
|
||||
MissingRequiredDependency,
|
||||
IncompatibleDependency,
|
||||
UnsupportedContentType,
|
||||
NoCompatibleRelease,
|
||||
NoCompatibleShaderRuntime,
|
||||
ShaderRuntimeMissing,
|
||||
ShaderRuntimeUnknown,
|
||||
SearchLimitReached,
|
||||
KeepIncompatible,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceUpgradeIssue {
|
||||
pub code: InstanceUpgradeIssueCode,
|
||||
pub message: String,
|
||||
pub content_id: Option<String>,
|
||||
pub provider: Option<ContentProvider>,
|
||||
pub project_id: Option<String>,
|
||||
pub conflicting_project_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dependency_requirements: Vec<InstanceUpgradeDependencyRequirement>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceUpgradeDependencyRequirement {
|
||||
pub root_content_id: String,
|
||||
pub root_provider: ContentProvider,
|
||||
pub root_project_id: String,
|
||||
pub parent_provider: ContentProvider,
|
||||
pub parent_project_id: String,
|
||||
pub parent_release_id: String,
|
||||
pub dependency_provider: ContentProvider,
|
||||
pub dependency_project_id: String,
|
||||
pub required_release_id: Option<String>,
|
||||
pub candidate_release_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstanceUpgradeDependencyChangeKind {
|
||||
Add,
|
||||
Upgrade,
|
||||
Keep,
|
||||
Remove,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceUpgradeDependencyChange {
|
||||
/// Existing physical ContentEntry targeted by this change, when present.
|
||||
#[serde(default)]
|
||||
pub existing_content_id: Option<String>,
|
||||
pub provider: ContentProvider,
|
||||
pub project_id: String,
|
||||
pub current_release_id: Option<String>,
|
||||
pub target_release_id: Option<String>,
|
||||
pub kind: InstanceUpgradeDependencyChangeKind,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceUpgradeSourceFile {
|
||||
pub relative_path: String,
|
||||
pub sha1: String,
|
||||
pub size: u64,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceUpgradeSelection {
|
||||
pub content_id: String,
|
||||
pub provider: Option<ContentProvider>,
|
||||
pub project_id: Option<String>,
|
||||
pub current_release_id: Option<String>,
|
||||
pub target_release_id: Option<String>,
|
||||
pub action: InstanceUpgradeAction,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstanceUpgradeSolutionKind {
|
||||
Newest,
|
||||
MinimalChange,
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceUpgradeSolution {
|
||||
pub kind: InstanceUpgradeSolutionKind,
|
||||
pub selections: Vec<InstanceUpgradeSelection>,
|
||||
pub dependency_changes: Vec<InstanceUpgradeDependencyChange>,
|
||||
pub warnings: Vec<InstanceUpgradeIssue>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstanceUpgradeSolutionChoice {
|
||||
Newest,
|
||||
MinimalChange,
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceUpgradeFixedConstraint {
|
||||
pub content_id: String,
|
||||
pub provider: ContentProvider,
|
||||
pub project_id: String,
|
||||
pub version_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceUpgradePlan {
|
||||
pub id: String,
|
||||
pub instance_id: String,
|
||||
pub source_revision: u64,
|
||||
#[serde(default)]
|
||||
pub source_files: Vec<InstanceUpgradeSourceFile>,
|
||||
pub source_environment: InstanceUpgradeEnvironment,
|
||||
pub target_environment: InstanceUpgradeEnvironment,
|
||||
pub items: Vec<InstanceUpgradeItem>,
|
||||
pub dependency_changes: Vec<InstanceUpgradeDependencyChange>,
|
||||
pub warnings: Vec<InstanceUpgradeIssue>,
|
||||
pub blocking_issues: Vec<InstanceUpgradeIssue>,
|
||||
pub newest_solution: Option<InstanceUpgradeSolution>,
|
||||
pub minimal_change_solution: Option<InstanceUpgradeSolution>,
|
||||
pub selected_solution: Option<InstanceUpgradeSolution>,
|
||||
#[serde(default)]
|
||||
pub custom_constraints: Vec<InstanceUpgradeFixedConstraint>,
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use crate::state::{
|
||||
ContentSet, Hooks, Instance, InstanceLink, MemorySettings, WindowSize,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceLaunchOverrides {
|
||||
pub instance_id: String,
|
||||
pub java_path: Option<String>,
|
||||
pub extra_launch_args: Option<Vec<String>>,
|
||||
pub custom_env_vars: Option<Vec<(String, String)>>,
|
||||
pub memory: Option<MemorySettings>,
|
||||
pub force_fullscreen: Option<bool>,
|
||||
pub maximize_window: Option<bool>,
|
||||
pub game_resolution: Option<WindowSize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub launch_preparation_timeout: Option<u64>,
|
||||
pub hooks: Hooks,
|
||||
}
|
||||
|
||||
impl InstanceLaunchOverrides {
|
||||
pub fn empty(instance_id: String) -> Self {
|
||||
Self {
|
||||
instance_id,
|
||||
java_path: None,
|
||||
extra_launch_args: None,
|
||||
custom_env_vars: None,
|
||||
memory: None,
|
||||
force_fullscreen: None,
|
||||
maximize_window: None,
|
||||
game_resolution: None,
|
||||
launch_preparation_timeout: None,
|
||||
hooks: Hooks {
|
||||
pre_launch: None,
|
||||
wrapper: None,
|
||||
post_exit: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct InstanceLaunchOverridesData {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub java_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub extra_launch_args: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom_env_vars: Option<Vec<(String, String)>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memory: Option<MemorySettings>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub force_fullscreen: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub maximize_window: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub game_resolution: Option<WindowSize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub launch_preparation_timeout: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub hooks: Hooks,
|
||||
}
|
||||
|
||||
impl InstanceLaunchOverridesData {
|
||||
pub(crate) fn into_launch_overrides(
|
||||
self,
|
||||
instance_id: String,
|
||||
) -> InstanceLaunchOverrides {
|
||||
InstanceLaunchOverrides {
|
||||
instance_id,
|
||||
java_path: self.java_path,
|
||||
extra_launch_args: self.extra_launch_args,
|
||||
custom_env_vars: self.custom_env_vars,
|
||||
memory: self.memory,
|
||||
force_fullscreen: self.force_fullscreen,
|
||||
maximize_window: self.maximize_window,
|
||||
game_resolution: self.game_resolution,
|
||||
launch_preparation_timeout: self.launch_preparation_timeout,
|
||||
hooks: self.hooks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&InstanceLaunchOverrides> for InstanceLaunchOverridesData {
|
||||
fn from(overrides: &InstanceLaunchOverrides) -> Self {
|
||||
Self {
|
||||
java_path: overrides.java_path.clone(),
|
||||
extra_launch_args: overrides.extra_launch_args.clone(),
|
||||
custom_env_vars: overrides.custom_env_vars.clone(),
|
||||
memory: overrides.memory,
|
||||
force_fullscreen: overrides.force_fullscreen,
|
||||
maximize_window: overrides.maximize_window,
|
||||
game_resolution: overrides.game_resolution,
|
||||
launch_preparation_timeout: overrides.launch_preparation_timeout,
|
||||
hooks: overrides.hooks.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceLaunchContext {
|
||||
pub instance: Instance,
|
||||
pub applied_content_set: ContentSet,
|
||||
pub link: InstanceLink,
|
||||
pub launch_overrides: InstanceLaunchOverrides,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstanceLink {
|
||||
Unmanaged,
|
||||
ModrinthModpack {
|
||||
project_id: String,
|
||||
version_id: String,
|
||||
},
|
||||
/// A CurseForge modpack managed by project/file IDs.
|
||||
CurseForgeModpack {
|
||||
project_id: String,
|
||||
version_id: String,
|
||||
},
|
||||
ServerProject {
|
||||
project_id: String,
|
||||
},
|
||||
/// A server project that points at a separate content project/version.
|
||||
ServerProjectModpack {
|
||||
server_project_id: String,
|
||||
content_project_id: String,
|
||||
content_version_id: String,
|
||||
},
|
||||
/// A custom modpack source without a Modrinth project/version link.
|
||||
ImportedModpack {
|
||||
project_id: Option<String>,
|
||||
version_id: Option<String>,
|
||||
name: Option<String>,
|
||||
version_number: Option<String>,
|
||||
filename: Option<String>,
|
||||
},
|
||||
SharedInstance {
|
||||
shared_instance_id: Uuid,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
use crate::state::ModLoader;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::unknown_value;
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Eq,
|
||||
Hash,
|
||||
Ord,
|
||||
PartialEq,
|
||||
PartialOrd,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LoaderComponentKind {
|
||||
Vanilla,
|
||||
Forge,
|
||||
#[serde(rename = "neoforge", alias = "neo_forge")]
|
||||
NeoForge,
|
||||
Fabric,
|
||||
Quilt,
|
||||
Cleanroom,
|
||||
LegacyFabric,
|
||||
Babric,
|
||||
#[serde(rename = "optifine", alias = "opti_fine")]
|
||||
OptiFine,
|
||||
LiteLoader,
|
||||
#[serde(rename = "optifabric", alias = "opti_fabric")]
|
||||
OptiFabric,
|
||||
}
|
||||
|
||||
impl LoaderComponentKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Vanilla => "vanilla",
|
||||
Self::Forge => "forge",
|
||||
Self::NeoForge => "neoforge",
|
||||
Self::Fabric => "fabric",
|
||||
Self::Quilt => "quilt",
|
||||
Self::Cleanroom => "cleanroom",
|
||||
Self::LegacyFabric => "legacy_fabric",
|
||||
Self::Babric => "babric",
|
||||
Self::OptiFine => "optifine",
|
||||
Self::LiteLoader => "lite_loader",
|
||||
Self::OptiFabric => "optifabric",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"vanilla" => Ok(Self::Vanilla),
|
||||
"forge" => Ok(Self::Forge),
|
||||
"neoforge" => Ok(Self::NeoForge),
|
||||
"fabric" => Ok(Self::Fabric),
|
||||
"quilt" => Ok(Self::Quilt),
|
||||
"cleanroom" => Ok(Self::Cleanroom),
|
||||
"legacy_fabric" => Ok(Self::LegacyFabric),
|
||||
"babric" => Ok(Self::Babric),
|
||||
"optifine" => Ok(Self::OptiFine),
|
||||
"lite_loader" => Ok(Self::LiteLoader),
|
||||
"optifabric" => Ok(Self::OptiFabric),
|
||||
other => Err(unknown_value("loader component kind", other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_loader(loader: ModLoader) -> Self {
|
||||
match loader {
|
||||
ModLoader::Vanilla => Self::Vanilla,
|
||||
ModLoader::Forge => Self::Forge,
|
||||
ModLoader::NeoForge => Self::NeoForge,
|
||||
ModLoader::Fabric => Self::Fabric,
|
||||
ModLoader::Quilt => Self::Quilt,
|
||||
ModLoader::Cleanroom => Self::Cleanroom,
|
||||
ModLoader::LegacyFabric => Self::LegacyFabric,
|
||||
ModLoader::Babric => Self::Babric,
|
||||
ModLoader::OptiFine => Self::OptiFine,
|
||||
ModLoader::LiteLoader => Self::LiteLoader,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_loader(self) -> Option<ModLoader> {
|
||||
match self {
|
||||
Self::Vanilla => Some(ModLoader::Vanilla),
|
||||
Self::Forge => Some(ModLoader::Forge),
|
||||
Self::NeoForge => Some(ModLoader::NeoForge),
|
||||
Self::Fabric => Some(ModLoader::Fabric),
|
||||
Self::Quilt => Some(ModLoader::Quilt),
|
||||
Self::Cleanroom => Some(ModLoader::Cleanroom),
|
||||
Self::LegacyFabric => Some(ModLoader::LegacyFabric),
|
||||
Self::Babric => Some(ModLoader::Babric),
|
||||
Self::OptiFine => Some(ModLoader::OptiFine),
|
||||
Self::LiteLoader => Some(ModLoader::LiteLoader),
|
||||
Self::OptiFabric => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LoaderComponentRole {
|
||||
Primary,
|
||||
Adjunct,
|
||||
}
|
||||
|
||||
impl LoaderComponentRole {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Primary => "primary",
|
||||
Self::Adjunct => "adjunct",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"primary" => Ok(Self::Primary),
|
||||
"adjunct" => Ok(Self::Adjunct),
|
||||
other => Err(unknown_value("loader component role", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoaderComponent {
|
||||
pub instance_id: String,
|
||||
pub kind: LoaderComponentKind,
|
||||
pub version: Option<String>,
|
||||
pub role: LoaderComponentRole,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl LoaderComponent {
|
||||
pub fn new_primary(
|
||||
instance_id: impl Into<String>,
|
||||
loader: ModLoader,
|
||||
version: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
instance_id: instance_id.into(),
|
||||
kind: LoaderComponentKind::from_loader(loader),
|
||||
version,
|
||||
role: LoaderComponentRole::Primary,
|
||||
provider_metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_legacy_projection(
|
||||
instance_id: impl Into<String>,
|
||||
loader: ModLoader,
|
||||
version: Option<String>,
|
||||
) -> Vec<Self> {
|
||||
let instance_id = instance_id.into();
|
||||
match loader {
|
||||
ModLoader::OptiFine | ModLoader::LiteLoader => vec![
|
||||
Self::new_primary(
|
||||
instance_id.clone(),
|
||||
ModLoader::Vanilla,
|
||||
None,
|
||||
),
|
||||
Self {
|
||||
instance_id,
|
||||
kind: LoaderComponentKind::from_loader(loader),
|
||||
version,
|
||||
role: LoaderComponentRole::Adjunct,
|
||||
provider_metadata: None,
|
||||
},
|
||||
],
|
||||
_ => vec![Self::new_primary(instance_id, loader, version)],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn project_loader_components(
|
||||
components: &[LoaderComponent],
|
||||
) -> crate::Result<(ModLoader, Option<String>)> {
|
||||
let primary = components
|
||||
.iter()
|
||||
.find(|component| component.role == LoaderComponentRole::Primary)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"Loader component set has no primary component".to_string(),
|
||||
)
|
||||
.as_error()
|
||||
})?;
|
||||
let primary_loader = primary.kind.as_loader().ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"OptiFabric cannot be a primary loader component".to_string(),
|
||||
)
|
||||
.as_error()
|
||||
})?;
|
||||
|
||||
if primary_loader != ModLoader::Vanilla {
|
||||
return Ok((primary_loader, primary.version.clone()));
|
||||
}
|
||||
|
||||
for kind in [
|
||||
LoaderComponentKind::OptiFine,
|
||||
LoaderComponentKind::LiteLoader,
|
||||
] {
|
||||
if let Some(adjunct) = components.iter().find(|component| {
|
||||
component.role == LoaderComponentRole::Adjunct
|
||||
&& component.kind == kind
|
||||
}) {
|
||||
return Ok((
|
||||
kind.as_loader().expect("projectable adjunct"),
|
||||
adjunct.version.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok((ModLoader::Vanilla, None))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn forge_primary_wins_over_optifine_adjunct() {
|
||||
let components = vec![
|
||||
LoaderComponent::new_primary(
|
||||
"instance",
|
||||
ModLoader::Forge,
|
||||
Some("47.4.0".to_string()),
|
||||
),
|
||||
LoaderComponent {
|
||||
instance_id: "instance".to_string(),
|
||||
kind: LoaderComponentKind::OptiFine,
|
||||
version: Some("HD_U_I6".to_string()),
|
||||
role: LoaderComponentRole::Adjunct,
|
||||
provider_metadata: None,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
project_loader_components(&components).unwrap(),
|
||||
(ModLoader::Forge, Some("47.4.0".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_optifine_preserves_legacy_projection() {
|
||||
let components = LoaderComponent::from_legacy_projection(
|
||||
"instance",
|
||||
ModLoader::OptiFine,
|
||||
Some("HD_U_I6".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
project_loader_components(&components).unwrap(),
|
||||
(ModLoader::OptiFine, Some("HD_U_I6".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loader_wire_names_are_canonical_and_accept_legacy_aliases() {
|
||||
for (loader, canonical, legacy_alias) in [
|
||||
(ModLoader::NeoForge, "neoforge", "neo_forge"),
|
||||
(ModLoader::OptiFine, "optifine", "opti_fine"),
|
||||
] {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&loader).unwrap(),
|
||||
format!("\"{canonical}\"")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ModLoader>(&format!("\"{canonical}\""))
|
||||
.unwrap(),
|
||||
loader
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ModLoader>(&format!(
|
||||
"\"{legacy_alias}\""
|
||||
))
|
||||
.unwrap(),
|
||||
loader
|
||||
);
|
||||
}
|
||||
|
||||
for (kind, canonical, legacy_alias) in [
|
||||
(LoaderComponentKind::NeoForge, "neoforge", "neo_forge"),
|
||||
(LoaderComponentKind::OptiFine, "optifine", "opti_fine"),
|
||||
(LoaderComponentKind::OptiFabric, "optifabric", "opti_fabric"),
|
||||
] {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&kind).unwrap(),
|
||||
format!("\"{canonical}\"")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<LoaderComponentKind>(&format!(
|
||||
"\"{legacy_alias}\""
|
||||
))
|
||||
.unwrap(),
|
||||
kind
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{ContentEntry, InstanceFile};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceContentManifest {
|
||||
pub instance_id: String,
|
||||
pub content_set_id: String,
|
||||
pub entries: Vec<ContentEntry>,
|
||||
pub files: Vec<InstanceFile>,
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod content_entry;
|
||||
pub use self::content_entry::*;
|
||||
|
||||
mod content_dependency;
|
||||
pub use self::content_dependency::*;
|
||||
|
||||
mod dependency_resolution;
|
||||
pub use self::dependency_resolution::*;
|
||||
|
||||
mod content_ownership;
|
||||
pub use self::content_ownership::*;
|
||||
|
||||
mod content_snapshot;
|
||||
pub use self::content_snapshot::*;
|
||||
|
||||
mod content_update_plan;
|
||||
pub use self::content_update_plan::*;
|
||||
|
||||
mod instance_upgrade_plan;
|
||||
pub use self::instance_upgrade_plan::*;
|
||||
|
||||
mod post_upgrade_notice;
|
||||
pub use self::post_upgrade_notice::*;
|
||||
|
||||
mod content_provider;
|
||||
pub use self::content_provider::*;
|
||||
|
||||
mod content_set;
|
||||
pub use self::content_set::*;
|
||||
|
||||
mod content_set_remote_ref;
|
||||
pub use self::content_set_remote_ref::*;
|
||||
|
||||
mod content_set_sync_state;
|
||||
pub use self::content_set_sync_state::*;
|
||||
|
||||
mod core_component;
|
||||
pub use self::core_component::*;
|
||||
|
||||
mod file;
|
||||
pub use self::file::*;
|
||||
|
||||
mod instance;
|
||||
pub use self::instance::*;
|
||||
|
||||
mod install_candidate;
|
||||
pub use self::install_candidate::*;
|
||||
|
||||
mod launch;
|
||||
pub use self::launch::*;
|
||||
|
||||
mod link;
|
||||
pub use self::link::*;
|
||||
|
||||
mod loader_component;
|
||||
pub use self::loader_component::*;
|
||||
|
||||
mod manifest;
|
||||
|
||||
mod update_check;
|
||||
pub use self::update_check::*;
|
||||
|
||||
fn unknown_value(kind: &str, value: &str) -> crate::Error {
|
||||
crate::ErrorKind::InputError(format!("Unknown {kind} {value}")).into()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::InstanceUpgradeIssueCode;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstancePostUpgradeWarning {
|
||||
pub code: InstanceUpgradeIssueCode,
|
||||
pub content_id: Option<String>,
|
||||
pub relative_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstancePostUpgradeNotice {
|
||||
pub instance_id: String,
|
||||
pub upgrade_job_id: String,
|
||||
pub target_game_version: String,
|
||||
pub consecutive_clean_launches: u8,
|
||||
pub warnings: Vec<InstancePostUpgradeWarning>,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use crate::state::{ContentProvider, ReleaseChannel};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ContentUpdateCheck {
|
||||
pub content_entry_id: String,
|
||||
pub update_channel: ReleaseChannel,
|
||||
pub provider: Option<ContentProvider>,
|
||||
pub provider_project_id: Option<String>,
|
||||
pub provider_release_id: Option<String>,
|
||||
pub checked_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
use crate::State;
|
||||
use crate::event::InstancePayloadType;
|
||||
use crate::event::emit::{emit_instance, emit_minecraft_crash_warning};
|
||||
use crate::state::{
|
||||
DirectoryInfo, InstanceInstallStage, ProjectType, attached_world_data,
|
||||
};
|
||||
use crate::worlds::WorldType;
|
||||
use notify::{RecommendedWatcher, RecursiveMode};
|
||||
use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::{RwLock, mpsc::channel};
|
||||
|
||||
use super::adapters::sqlite::instance_rows;
|
||||
use super::config_sync::{CONFIG_FILE_NAME, CONFIG_FILE_TEMP_NAME};
|
||||
|
||||
pub struct FileWatcher {
|
||||
watcher: RwLock<Debouncer<RecommendedWatcher>>,
|
||||
instance_ids: Arc<RwLock<HashMap<String, String>>>,
|
||||
content_changes: Arc<RwLock<HashMap<String, InstanceContentChangeState>>>,
|
||||
manual_import_directory: Arc<RwLock<Option<PathBuf>>>,
|
||||
manual_import_generation: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct InstanceContentWatchSnapshot {
|
||||
pub epoch: u64,
|
||||
pub generation: u64,
|
||||
pub dirty_paths: HashSet<String>,
|
||||
pub directory_dirty: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct InstanceContentChangeState {
|
||||
epoch: u64,
|
||||
generation: u64,
|
||||
dirty_paths: HashSet<String>,
|
||||
directory_dirty: bool,
|
||||
tracked_paths: HashSet<String>,
|
||||
}
|
||||
|
||||
static NEXT_CONTENT_EPOCH: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
pub async fn init_watcher() -> crate::Result<FileWatcher> {
|
||||
let (tx, mut rx) = channel(1);
|
||||
let instance_ids = Arc::new(RwLock::new(HashMap::<String, String>::new()));
|
||||
let event_instance_ids = instance_ids.clone();
|
||||
let content_changes = Arc::new(RwLock::new(HashMap::<
|
||||
String,
|
||||
InstanceContentChangeState,
|
||||
>::new()));
|
||||
let event_content_changes = content_changes.clone();
|
||||
let manual_import_directory = Arc::new(RwLock::new(None::<PathBuf>));
|
||||
let event_manual_import_directory = manual_import_directory.clone();
|
||||
let manual_import_generation = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let file_watcher = new_debouncer(
|
||||
Duration::from_secs_f32(1.0),
|
||||
move |res: DebounceEventResult| {
|
||||
tx.blocking_send(res).ok();
|
||||
},
|
||||
)?;
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
let span = tracing::span!(tracing::Level::INFO, "init_watcher");
|
||||
tracing::info!(parent: &span, "Initing watcher");
|
||||
while let Some(res) = rx.recv().await {
|
||||
let _span = span.enter();
|
||||
|
||||
match res {
|
||||
Ok(events) => {
|
||||
let instance_ids = event_instance_ids.read().await;
|
||||
let manual_import_directory =
|
||||
event_manual_import_directory.read().await.clone();
|
||||
let mut visited_instances = Vec::new();
|
||||
let mut scan_manual_downloads = false;
|
||||
|
||||
for e in &events {
|
||||
let mut instance_path = None;
|
||||
|
||||
let mut found = false;
|
||||
for component in e.path.components() {
|
||||
if found {
|
||||
instance_path = Some(component.as_os_str());
|
||||
break;
|
||||
}
|
||||
|
||||
if component.as_os_str()
|
||||
== crate::state::dirs::INSTANCES_FOLDER_NAME
|
||||
{
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(instance_path) = instance_path {
|
||||
let instance_path_str =
|
||||
instance_path.to_string_lossy().to_string();
|
||||
let Some(instance_id) =
|
||||
instance_ids.get(&instance_path_str).cloned()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let first_file_name = e
|
||||
.path
|
||||
.components()
|
||||
.skip_while(|x| x.as_os_str() != instance_path)
|
||||
.nth(1)
|
||||
.map(|x| x.as_os_str());
|
||||
let relative_path = e
|
||||
.path
|
||||
.components()
|
||||
.skip_while(|x| x.as_os_str() != instance_path)
|
||||
.skip(1)
|
||||
.map(|component| {
|
||||
component.as_os_str().to_string_lossy()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
if !relative_path.is_empty() {
|
||||
record_upgrade_content_change(
|
||||
&event_content_changes,
|
||||
&instance_id,
|
||||
&relative_path,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if first_file_name
|
||||
.is_some_and(is_config_sync_file_name)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let is_crash_report = first_file_name
|
||||
.as_ref()
|
||||
.is_some_and(|x| *x == "crash-reports")
|
||||
&& e.path
|
||||
.extension()
|
||||
.as_ref()
|
||||
.is_some_and(|x| *x == "txt");
|
||||
let is_jvm_crash =
|
||||
first_file_name.as_ref().is_some_and(|x| {
|
||||
x.to_string_lossy()
|
||||
.starts_with("hs_err_pid")
|
||||
}) && e
|
||||
.path
|
||||
.extension()
|
||||
.as_ref()
|
||||
.is_some_and(|x| *x == "log");
|
||||
if is_crash_report || is_jvm_crash {
|
||||
crash_task(instance_id);
|
||||
} else if !visited_instances.contains(&instance_id)
|
||||
{
|
||||
let event = if first_file_name
|
||||
.as_ref()
|
||||
.is_some_and(|x| *x == "servers.dat")
|
||||
{
|
||||
Some(InstancePayloadType::ServersUpdated)
|
||||
} else if first_file_name.as_ref().is_some_and(
|
||||
|x| {
|
||||
*x == "saves"
|
||||
&& e.path
|
||||
.file_name()
|
||||
.as_ref()
|
||||
.is_some_and(|x| {
|
||||
*x == "level.dat"
|
||||
})
|
||||
},
|
||||
) {
|
||||
tracing::info!(
|
||||
"World updated: {}",
|
||||
e.path.display()
|
||||
);
|
||||
let world = e
|
||||
.path
|
||||
.parent()
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
if !e.path.is_file() {
|
||||
let instance_id = instance_id.clone();
|
||||
let world = world.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Ok(state) = State::get().await
|
||||
&& let Err(e) = attached_world_data::AttachedWorldData::remove_for_world(
|
||||
&instance_id,
|
||||
WorldType::Singleplayer,
|
||||
&world,
|
||||
&state.pool
|
||||
).await {
|
||||
tracing::warn!("Failed to remove AttachedWorldData for '{world}': {e}")
|
||||
}
|
||||
});
|
||||
}
|
||||
Some(InstancePayloadType::WorldUpdated {
|
||||
world,
|
||||
})
|
||||
} else if first_file_name
|
||||
.as_ref()
|
||||
.is_none_or(|x| *x != "saves")
|
||||
{
|
||||
Some(InstancePayloadType::Synced)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(event) = event {
|
||||
let emit_instance_id = instance_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = emit_instance(
|
||||
&emit_instance_id,
|
||||
event,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
visited_instances.push(instance_id);
|
||||
}
|
||||
}
|
||||
} else if manual_import_directory.as_ref().is_some_and(
|
||||
|directory| e.path.starts_with(directory),
|
||||
) {
|
||||
scan_manual_downloads = true;
|
||||
}
|
||||
}
|
||||
if scan_manual_downloads
|
||||
&& let Some(directory) = manual_import_directory
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) =
|
||||
crate::api::curseforge::scan_pending_manual_downloads_in(
|
||||
&directory,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Unable to scan pending manual downloads: {error}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(error) => tracing::warn!("Unable to watch file: {error}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(FileWatcher {
|
||||
watcher: RwLock::new(file_watcher),
|
||||
instance_ids,
|
||||
content_changes,
|
||||
manual_import_directory,
|
||||
manual_import_generation,
|
||||
})
|
||||
}
|
||||
|
||||
impl FileWatcher {
|
||||
pub(crate) async fn track_upgrade_source(
|
||||
&self,
|
||||
instance_id: &str,
|
||||
paths: impl IntoIterator<Item = String>,
|
||||
) -> Option<InstanceContentWatchSnapshot> {
|
||||
let mut changes = self.content_changes.write().await;
|
||||
let change = changes.get_mut(instance_id)?;
|
||||
change.tracked_paths.extend(paths);
|
||||
Some(change.snapshot())
|
||||
}
|
||||
|
||||
pub(crate) async fn content_watch_snapshot(
|
||||
&self,
|
||||
instance_id: &str,
|
||||
) -> Option<InstanceContentWatchSnapshot> {
|
||||
self.content_changes
|
||||
.read()
|
||||
.await
|
||||
.get(instance_id)
|
||||
.map(InstanceContentChangeState::snapshot)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn record_upgrade_content_change(
|
||||
&self,
|
||||
instance_id: &str,
|
||||
relative_path: &str,
|
||||
) {
|
||||
record_upgrade_content_change(
|
||||
&self.content_changes,
|
||||
instance_id,
|
||||
relative_path,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn configure_manual_import_directory(
|
||||
&self,
|
||||
directory: Option<PathBuf>,
|
||||
) -> crate::Result<()> {
|
||||
let current = self.manual_import_directory.read().await.clone();
|
||||
if current == directory {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut debouncer = self.watcher.write().await;
|
||||
if let Some(directory) = directory.as_ref() {
|
||||
debouncer
|
||||
.watcher()
|
||||
.watch(directory, RecursiveMode::NonRecursive)?;
|
||||
}
|
||||
if let Some(current) = current.as_ref() {
|
||||
let _ = debouncer.watcher().unwatch(current);
|
||||
}
|
||||
*self.manual_import_directory.write().await = directory;
|
||||
let generation = self
|
||||
.manual_import_generation
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
+ 1;
|
||||
let Some(directory) = self.manual_import_directory.read().await.clone()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let active_generation = self.manual_import_generation.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(3));
|
||||
interval.set_missed_tick_behavior(
|
||||
tokio::time::MissedTickBehavior::Skip,
|
||||
);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if active_generation.load(Ordering::Relaxed) != generation {
|
||||
break;
|
||||
}
|
||||
if let Err(error) =
|
||||
crate::api::curseforge::scan_pending_manual_downloads_in(
|
||||
&directory,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Unable to poll pending manual downloads: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn watch_instances_init(
|
||||
watcher: &FileWatcher,
|
||||
dirs: &DirectoryInfo,
|
||||
pool: &sqlx::SqlitePool,
|
||||
) {
|
||||
let Ok(instances) = instance_rows::list_instances(pool).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
for instance in instances {
|
||||
watch_instance_folder(
|
||||
&instance.id,
|
||||
&instance.path,
|
||||
&dirs.instance_game_dir(&instance),
|
||||
watcher,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn watch_instance_folder(
|
||||
instance_id: &str,
|
||||
instance_path: &str,
|
||||
full_instance_path: &Path,
|
||||
watcher: &FileWatcher,
|
||||
) {
|
||||
let Ok(metadata) = tokio::fs::metadata(full_instance_path).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !metadata.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut to_watch = Vec::new();
|
||||
for full_path in instance_watch_paths(full_instance_path) {
|
||||
if &full_path == full_instance_path {
|
||||
// The root is watched non-recursively after the subfolders.
|
||||
continue;
|
||||
}
|
||||
let meta = tokio::fs::symlink_metadata(&full_path).await;
|
||||
let exists = meta.is_ok();
|
||||
let is_symlink = meta.ok().is_some_and(|m| m.file_type().is_symlink());
|
||||
let sub_path = full_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !exists
|
||||
&& !is_symlink
|
||||
&& !sub_path.contains('.')
|
||||
&& let Err(e) = crate::util::io::create_dir_all(&full_path).await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to create directory for watcher {full_path:?}: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
to_watch.push(full_path);
|
||||
}
|
||||
|
||||
let mut debouncer = watcher.watcher.write().await;
|
||||
for full_path in &to_watch {
|
||||
if let Err(e) = debouncer
|
||||
.watcher()
|
||||
.watch(full_path, RecursiveMode::Recursive)
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to watch directory for watcher {full_path:?}: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = debouncer
|
||||
.watcher()
|
||||
.watch(full_instance_path, RecursiveMode::NonRecursive)
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to watch root instance directory for watcher {full_instance_path:?}: {e}"
|
||||
);
|
||||
}
|
||||
|
||||
watcher
|
||||
.instance_ids
|
||||
.write()
|
||||
.await
|
||||
.insert(instance_path.to_string(), instance_id.to_string());
|
||||
watcher
|
||||
.content_changes
|
||||
.write()
|
||||
.await
|
||||
.insert(instance_id.to_string(), new_instance_content_change_state());
|
||||
}
|
||||
|
||||
/// Stops watching an instance folder and forgets its instance-id mapping.
|
||||
///
|
||||
/// Used when the instance folder is about to be renamed or replaced. On
|
||||
/// Windows an active watch keeps an open directory handle, which blocks
|
||||
/// renaming the folder with `ERROR_ACCESS_DENIED`; the folder must be
|
||||
/// unwatched first and re-registered afterwards.
|
||||
pub(crate) async fn unwatch_instance_folder(
|
||||
instance_path: &str,
|
||||
full_instance_path: &Path,
|
||||
watcher: &FileWatcher,
|
||||
) {
|
||||
let mut debouncer = watcher.watcher.write().await;
|
||||
for full_path in instance_watch_paths(full_instance_path) {
|
||||
let _ = debouncer.watcher().unwatch(&full_path);
|
||||
}
|
||||
|
||||
let instance_id = watcher.instance_ids.write().await.remove(instance_path);
|
||||
if let Some(instance_id) = instance_id {
|
||||
watcher.content_changes.write().await.remove(&instance_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl InstanceContentChangeState {
|
||||
fn snapshot(&self) -> InstanceContentWatchSnapshot {
|
||||
InstanceContentWatchSnapshot {
|
||||
epoch: self.epoch,
|
||||
generation: self.generation,
|
||||
dirty_paths: self.dirty_paths.clone(),
|
||||
directory_dirty: self.directory_dirty,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_instance_content_change_state() -> InstanceContentChangeState {
|
||||
InstanceContentChangeState {
|
||||
epoch: NEXT_CONTENT_EPOCH.fetch_add(1, Ordering::Relaxed),
|
||||
..InstanceContentChangeState::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_upgrade_content_change(
|
||||
relative_path: &str,
|
||||
tracked_paths: &HashSet<String>,
|
||||
) -> bool {
|
||||
let normalized = relative_path.replace('\\', "/");
|
||||
let top_level = normalized.split('/').next().unwrap_or_default();
|
||||
matches!(
|
||||
top_level,
|
||||
"mods" | "resourcepacks" | "shaderpacks" | "datapacks"
|
||||
) || tracked_paths.contains(&normalized)
|
||||
}
|
||||
|
||||
async fn record_upgrade_content_change(
|
||||
content_changes: &RwLock<HashMap<String, InstanceContentChangeState>>,
|
||||
instance_id: &str,
|
||||
relative_path: &str,
|
||||
) {
|
||||
let mut content_changes = content_changes.write().await;
|
||||
if let Some(change) = content_changes.get_mut(instance_id)
|
||||
&& is_upgrade_content_change(relative_path, &change.tracked_paths)
|
||||
{
|
||||
change.generation = change.generation.wrapping_add(1);
|
||||
change.dirty_paths.insert(relative_path.replace('\\', "/"));
|
||||
change.directory_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// All paths `watch_instance_folder` registers for a single instance,
|
||||
/// including the root, so `unwatch_instance_folder` can release them again.
|
||||
fn instance_watch_paths(full_instance_path: &Path) -> Vec<PathBuf> {
|
||||
// `saves` is both a ProjectType folder and part of the crash-report
|
||||
// extras; deduplicate so watch/unwatch stay symmetric (a leftover watch
|
||||
// handle on a subfolder keeps Windows from renaming the instance root).
|
||||
let mut seen = HashSet::new();
|
||||
let mut paths = Vec::new();
|
||||
for sub in ProjectType::iterator()
|
||||
.map(|x| x.get_folder())
|
||||
.chain(["crash-reports", "saves"])
|
||||
{
|
||||
let full_path = full_instance_path.join(sub);
|
||||
if seen.insert(full_path.clone()) {
|
||||
paths.push(full_path);
|
||||
}
|
||||
}
|
||||
paths.push(full_instance_path.to_path_buf());
|
||||
paths
|
||||
}
|
||||
|
||||
fn crash_task(instance_id: String) {
|
||||
tokio::task::spawn(async move {
|
||||
let res = async {
|
||||
let state = State::get().await?;
|
||||
let Some(instance) =
|
||||
instance_rows::get_instance_by_id(&instance_id, &state.pool)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if instance.install_stage == InstanceInstallStage::Installed {
|
||||
emit_minecraft_crash_warning(&instance_id, &instance.name)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok::<(), crate::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!("Unable to send crash report to frontend: {err}")
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
fn is_config_sync_file_name(name: &std::ffi::OsStr) -> bool {
|
||||
let name = name.to_string_lossy();
|
||||
name == CONFIG_FILE_NAME || name == CONFIG_FILE_TEMP_NAME
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn watched_instance_folder_cannot_be_renamed_on_windows() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let dirs = DirectoryInfo {
|
||||
settings_dir: temp.path().to_path_buf(),
|
||||
config_dir: temp.path().to_path_buf(),
|
||||
app_identifier: "test".to_string(),
|
||||
};
|
||||
let watcher = init_watcher().await.unwrap();
|
||||
let instance_path = "watched-instance";
|
||||
let full_path = dirs.instances_dir().join(instance_path);
|
||||
std::fs::create_dir_all(&full_path).unwrap();
|
||||
|
||||
watch_instance_folder(
|
||||
"instance-1",
|
||||
instance_path,
|
||||
&full_path,
|
||||
&watcher,
|
||||
)
|
||||
.await;
|
||||
|
||||
// On Windows, an active watch keeps a directory handle open and blocks
|
||||
// renaming the instance folder (ERROR_ACCESS_DENIED). This is the
|
||||
// failure the symlink import used to hit.
|
||||
let rename_result = std::fs::rename(
|
||||
&full_path,
|
||||
temp.path().join("watched-instance.bak"),
|
||||
);
|
||||
assert!(
|
||||
rename_result.is_err(),
|
||||
"a watched folder must not be renameable on Windows"
|
||||
);
|
||||
|
||||
// The import flow unwatches the folder first; after that the rename
|
||||
// must succeed (the watcher closes its handles asynchronously, so a
|
||||
// short retry window is needed).
|
||||
unwatch_instance_folder(instance_path, &full_path, &watcher).await;
|
||||
|
||||
let mut renamed = false;
|
||||
for _ in 0..20 {
|
||||
match std::fs::rename(
|
||||
&full_path,
|
||||
temp.path().join("watched-instance.bak"),
|
||||
) {
|
||||
Ok(()) => {
|
||||
renamed = true;
|
||||
break;
|
||||
}
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::PermissionDenied =>
|
||||
{
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
Err(error) => panic!("unexpected rename error: {error:?}"),
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
renamed,
|
||||
"rename should succeed after the folder is unwatched"
|
||||
);
|
||||
|
||||
drop(watcher);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod config_file_name_tests {
|
||||
use super::*;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
#[test]
|
||||
fn recognizes_sync_config_files_but_not_other_instance_events() {
|
||||
assert!(is_config_sync_file_name(OsStr::new("axolotl_config.json")));
|
||||
assert!(is_config_sync_file_name(OsStr::new(
|
||||
"axolotl_config.json.tmp"
|
||||
)));
|
||||
assert!(!is_config_sync_file_name(OsStr::new("mods")));
|
||||
assert!(!is_config_sync_file_name(OsStr::new("servers.dat")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_change_tracking_ignores_unrelated_paths() {
|
||||
let watcher = init_watcher().await.unwrap();
|
||||
watcher.content_changes.write().await.insert(
|
||||
"instance".to_string(),
|
||||
new_instance_content_change_state(),
|
||||
);
|
||||
watcher
|
||||
.track_upgrade_source(
|
||||
"instance",
|
||||
["schematics/existing.schem".to_string()],
|
||||
)
|
||||
.await;
|
||||
|
||||
watcher
|
||||
.record_upgrade_content_change("instance", "config/options.txt")
|
||||
.await;
|
||||
watcher
|
||||
.record_upgrade_content_change(
|
||||
"instance",
|
||||
"schematics/existing.schem",
|
||||
)
|
||||
.await;
|
||||
watcher
|
||||
.record_upgrade_content_change("instance", "mods/new.jar")
|
||||
.await;
|
||||
|
||||
let snapshot =
|
||||
watcher.content_watch_snapshot("instance").await.unwrap();
|
||||
assert_eq!(snapshot.generation, 2);
|
||||
assert!(snapshot.dirty_paths.contains("mods/new.jar"));
|
||||
assert!(snapshot.dirty_paths.contains("schematics/existing.schem"));
|
||||
assert!(!snapshot.dirty_paths.contains("config/options.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_content_notifications_do_not_lose_generation() {
|
||||
let watcher = Arc::new(init_watcher().await.unwrap());
|
||||
watcher.content_changes.write().await.insert(
|
||||
"instance".to_string(),
|
||||
new_instance_content_change_state(),
|
||||
);
|
||||
let mut tasks = Vec::new();
|
||||
for index in 0..32 {
|
||||
let watcher = Arc::clone(&watcher);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
watcher
|
||||
.record_upgrade_content_change(
|
||||
"instance",
|
||||
&format!("mods/{index}.jar"),
|
||||
)
|
||||
.await;
|
||||
}));
|
||||
}
|
||||
for task in tasks {
|
||||
task.await.unwrap();
|
||||
}
|
||||
|
||||
let snapshot =
|
||||
watcher.content_watch_snapshot("instance").await.unwrap();
|
||||
assert_eq!(snapshot.generation, 32);
|
||||
assert_eq!(snapshot.dirty_paths.len(), 32);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watcher_reinitialization_changes_content_epoch() {
|
||||
let first = init_watcher().await.unwrap();
|
||||
let second = init_watcher().await.unwrap();
|
||||
first.content_changes.write().await.insert(
|
||||
"instance".to_string(),
|
||||
new_instance_content_change_state(),
|
||||
);
|
||||
second.content_changes.write().await.insert(
|
||||
"instance".to_string(),
|
||||
new_instance_content_change_state(),
|
||||
);
|
||||
assert_ne!(
|
||||
first
|
||||
.content_watch_snapshot("instance")
|
||||
.await
|
||||
.unwrap()
|
||||
.epoch,
|
||||
second
|
||||
.content_watch_snapshot("instance")
|
||||
.await
|
||||
.unwrap()
|
||||
.epoch
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user