feat:移除了弹窗,服务器添加sls
This commit is contained in:
177
packages/app-lib/src/state/attached_world_data.rs
Normal file
177
packages/app-lib/src/state/attached_world_data.rs
Normal file
@ -0,0 +1,177 @@
|
||||
use crate::worlds::{DisplayStatus, WorldType};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AttachedWorldData {
|
||||
pub display_status: DisplayStatus,
|
||||
pub project_id: Option<String>,
|
||||
pub content_kind: Option<String>,
|
||||
}
|
||||
|
||||
impl AttachedWorldData {
|
||||
pub async fn get_for_world(
|
||||
instance_id: &str,
|
||||
world_type: WorldType,
|
||||
world_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Option<Self>> {
|
||||
let world_type = world_type.as_str();
|
||||
|
||||
let attached_data = sqlx::query!(
|
||||
"
|
||||
SELECT display_status, project_id, content_kind
|
||||
FROM attached_world_data
|
||||
WHERE instance_id = ? and world_type = ? and world_id = ?
|
||||
",
|
||||
instance_id,
|
||||
world_type,
|
||||
world_id,
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
Ok(attached_data.map(|row| AttachedWorldData {
|
||||
display_status: DisplayStatus::from_string(&row.display_status),
|
||||
project_id: row.project_id,
|
||||
content_kind: row.content_kind,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_all_for_instance(
|
||||
instance_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<HashMap<(WorldType, String), Self>> {
|
||||
let attached_data = sqlx::query!(
|
||||
"
|
||||
SELECT world_type, world_id, display_status, project_id, content_kind
|
||||
FROM attached_world_data
|
||||
WHERE instance_id = ?
|
||||
",
|
||||
instance_id,
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
Ok(attached_data
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let world_type = WorldType::from_string(&row.world_type);
|
||||
let display_status =
|
||||
DisplayStatus::from_string(&row.display_status);
|
||||
(
|
||||
(world_type, row.world_id),
|
||||
AttachedWorldData {
|
||||
display_status,
|
||||
project_id: row.project_id,
|
||||
content_kind: row.content_kind,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn remove_for_world(
|
||||
instance_id: &str,
|
||||
world_type: WorldType,
|
||||
world_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let world_type = world_type.as_str();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM attached_world_data
|
||||
WHERE instance_id = ? and world_type = ? and world_id = ?
|
||||
",
|
||||
instance_id,
|
||||
world_type,
|
||||
world_id,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_display_status(
|
||||
instance_id: &str,
|
||||
world_type: WorldType,
|
||||
world_id: &str,
|
||||
display_status: DisplayStatus,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let world_type = world_type.as_str();
|
||||
let display_status = display_status.as_str();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO attached_world_data (instance_id, world_type, world_id, display_status)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (instance_id, world_type, world_id) DO UPDATE
|
||||
SET display_status = excluded.display_status
|
||||
",
|
||||
instance_id,
|
||||
world_type,
|
||||
world_id,
|
||||
display_status,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_project_id(
|
||||
instance_id: &str,
|
||||
world_type: WorldType,
|
||||
world_id: &str,
|
||||
project_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let world_type = world_type.as_str();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO attached_world_data (instance_id, world_type, world_id, project_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (instance_id, world_type, world_id) DO UPDATE
|
||||
SET project_id = excluded.project_id
|
||||
",
|
||||
instance_id,
|
||||
world_type,
|
||||
world_id,
|
||||
project_id,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_content_kind(
|
||||
instance_id: &str,
|
||||
world_type: WorldType,
|
||||
world_id: &str,
|
||||
content_kind: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let world_type = world_type.as_str();
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO attached_world_data (instance_id, world_type, world_id, content_kind)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (instance_id, world_type, world_id) DO UPDATE
|
||||
SET content_kind = excluded.content_kind
|
||||
",
|
||||
instance_id,
|
||||
world_type,
|
||||
world_id,
|
||||
content_kind,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
3613
packages/app-lib/src/state/cache.rs
Normal file
3613
packages/app-lib/src/state/cache.rs
Normal file
File diff suppressed because it is too large
Load Diff
365
packages/app-lib/src/state/content_favorites.rs
Normal file
365
packages/app-lib/src/state/content_favorites.rs
Normal file
@ -0,0 +1,365 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentFavoriteProvider {
|
||||
Modrinth,
|
||||
Curseforge,
|
||||
Mcarchive,
|
||||
}
|
||||
|
||||
impl ContentFavoriteProvider {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Modrinth => "modrinth",
|
||||
Self::Curseforge => "curseforge",
|
||||
Self::Mcarchive => "mcarchive",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"modrinth" => Ok(Self::Modrinth),
|
||||
"curseforge" => Ok(Self::Curseforge),
|
||||
"mcarchive" => Ok(Self::Mcarchive),
|
||||
_ => Err(crate::ErrorKind::InputError(format!(
|
||||
"Unsupported content favorite provider: {value}"
|
||||
))
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentFavoriteType {
|
||||
Mod,
|
||||
Resourcepack,
|
||||
Datapack,
|
||||
Shader,
|
||||
}
|
||||
|
||||
impl ContentFavoriteType {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Mod => "mod",
|
||||
Self::Resourcepack => "resourcepack",
|
||||
Self::Datapack => "datapack",
|
||||
Self::Shader => "shader",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(value: &str) -> crate::Result<Self> {
|
||||
match value {
|
||||
"mod" => Ok(Self::Mod),
|
||||
"resourcepack" => Ok(Self::Resourcepack),
|
||||
"datapack" => Ok(Self::Datapack),
|
||||
"shader" => Ok(Self::Shader),
|
||||
_ => Err(crate::ErrorKind::InputError(format!(
|
||||
"Unsupported content favorite type: {value}"
|
||||
))
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct ContentFavorite {
|
||||
pub provider: ContentFavoriteProvider,
|
||||
pub project_id: String,
|
||||
pub content_type: ContentFavoriteType,
|
||||
pub saved_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct ContentFavoriteInput {
|
||||
pub provider: ContentFavoriteProvider,
|
||||
pub project_id: String,
|
||||
pub content_type: ContentFavoriteType,
|
||||
}
|
||||
|
||||
impl ContentFavoriteInput {
|
||||
fn validate(&self) -> crate::Result<()> {
|
||||
if self.project_id.trim().is_empty() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"A content favorite must have a project ID".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn from_row(row: sqlx::sqlite::SqliteRow) -> crate::Result<ContentFavorite> {
|
||||
let provider: String = row.try_get("provider")?;
|
||||
let content_type: String = row.try_get("content_type")?;
|
||||
Ok(ContentFavorite {
|
||||
provider: ContentFavoriteProvider::parse(&provider)?,
|
||||
project_id: row.try_get("project_id")?,
|
||||
content_type: ContentFavoriteType::parse(&content_type)?,
|
||||
saved_at: row.try_get("saved_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Vec<ContentFavorite>> {
|
||||
let rows = sqlx::query(
|
||||
"
|
||||
SELECT provider, project_id, content_type, saved_at
|
||||
FROM content_favorites
|
||||
ORDER BY saved_at DESC, provider ASC, project_id ASC
|
||||
",
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(from_row).collect()
|
||||
}
|
||||
|
||||
pub async fn add(
|
||||
favorite: ContentFavoriteInput,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
) -> crate::Result<ContentFavorite> {
|
||||
favorite.validate()?;
|
||||
let project_id = favorite.project_id.trim();
|
||||
let provider = favorite.provider.as_str();
|
||||
let content_type = favorite.content_type.as_str();
|
||||
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO content_favorites (provider, project_id, content_type, saved_at)
|
||||
VALUES (?, ?, ?, CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
|
||||
ON CONFLICT (provider, project_id) DO UPDATE SET
|
||||
content_type = excluded.content_type
|
||||
",
|
||||
)
|
||||
.bind(provider)
|
||||
.bind(project_id)
|
||||
.bind(content_type)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
let row = sqlx::query(
|
||||
"
|
||||
SELECT provider, project_id, content_type, saved_at
|
||||
FROM content_favorites
|
||||
WHERE provider = ? AND project_id = ?
|
||||
",
|
||||
)
|
||||
.bind(provider)
|
||||
.bind(project_id)
|
||||
.fetch_one(exec)
|
||||
.await?;
|
||||
|
||||
from_row(row)
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
provider: ContentFavoriteProvider,
|
||||
project_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
if project_id.trim().is_empty() {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"A content favorite must have a project ID".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"DELETE FROM content_favorites WHERE provider = ? AND project_id = ?",
|
||||
)
|
||||
.bind(provider.as_str())
|
||||
.bind(project_id.trim())
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ContentFavoriteInput, ContentFavoriteProvider, ContentFavoriteType,
|
||||
add, list, remove,
|
||||
};
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use std::time::Duration;
|
||||
|
||||
async fn pool() -> sqlx::SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("in-memory database");
|
||||
sqlx::migrate!().run(&pool).await.expect("migrations");
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_favorites_migration_preserves_existing_database_rows() {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("in-memory database");
|
||||
sqlx::raw_sql(
|
||||
"CREATE TABLE existing_content (id INTEGER PRIMARY KEY, marker TEXT NOT NULL); INSERT INTO existing_content (marker) VALUES ('keep');",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("legacy database data");
|
||||
|
||||
sqlx::raw_sql(include_str!(
|
||||
"../../migrations/20260820200000_content-favorites.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("apply content favorites migration");
|
||||
|
||||
let marker: String = sqlx::query_scalar(
|
||||
"SELECT marker FROM existing_content WHERE id = 1",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("legacy row after migration");
|
||||
let table_exists: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'content_favorites'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("content favorites table query");
|
||||
|
||||
assert_eq!(marker, "keep");
|
||||
assert_eq!(table_exists, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_favorites_are_provider_qualified_and_idempotent() {
|
||||
let pool = pool().await;
|
||||
let modrinth = add(
|
||||
ContentFavoriteInput {
|
||||
provider: ContentFavoriteProvider::Modrinth,
|
||||
project_id: "same-id".to_string(),
|
||||
content_type: ContentFavoriteType::Mod,
|
||||
},
|
||||
&pool,
|
||||
)
|
||||
.await
|
||||
.expect("add Modrinth favorite");
|
||||
let duplicate = add(
|
||||
ContentFavoriteInput {
|
||||
provider: ContentFavoriteProvider::Modrinth,
|
||||
project_id: "same-id".to_string(),
|
||||
content_type: ContentFavoriteType::Mod,
|
||||
},
|
||||
&pool,
|
||||
)
|
||||
.await
|
||||
.expect("add duplicate favorite");
|
||||
add(
|
||||
ContentFavoriteInput {
|
||||
provider: ContentFavoriteProvider::Curseforge,
|
||||
project_id: "same-id".to_string(),
|
||||
content_type: ContentFavoriteType::Shader,
|
||||
},
|
||||
&pool,
|
||||
)
|
||||
.await
|
||||
.expect("add CurseForge favorite");
|
||||
|
||||
let favorites = list(&pool).await.expect("list favorites");
|
||||
assert_eq!(favorites.len(), 2);
|
||||
assert_eq!(duplicate.saved_at, modrinth.saved_at);
|
||||
assert!(favorites.iter().any(|favorite| {
|
||||
favorite.provider == ContentFavoriteProvider::Modrinth
|
||||
&& favorite.project_id == "same-id"
|
||||
}));
|
||||
assert!(favorites.iter().any(|favorite| {
|
||||
favorite.provider == ContentFavoriteProvider::Curseforge
|
||||
&& favorite.project_id == "same-id"
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_a_content_favorite_is_idempotent() {
|
||||
let pool = pool().await;
|
||||
remove(ContentFavoriteProvider::Modrinth, "missing", &pool)
|
||||
.await
|
||||
.expect("remove missing favorite");
|
||||
assert!(list(&pool).await.expect("list favorites").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn content_favorites_reject_invalid_database_values_and_sort_by_saved_time()
|
||||
{
|
||||
let pool = pool().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO content_favorites (provider, project_id, content_type, saved_at) VALUES ('modrinth', 'older', 'mod', 1)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert valid favorite");
|
||||
sqlx::query(
|
||||
"INSERT INTO content_favorites (provider, project_id, content_type, saved_at) VALUES ('curseforge', 'newer', 'shader', 2)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert valid favorite");
|
||||
|
||||
assert!(sqlx::query(
|
||||
"INSERT INTO content_favorites (provider, project_id, content_type, saved_at) VALUES ('unknown', 'bad-provider', 'mod', 3)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(sqlx::query(
|
||||
"INSERT INTO content_favorites (provider, project_id, content_type, saved_at) VALUES ('modrinth', 'bad-type', 'modpack', 3)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
let favorites = list(&pool).await.expect("list favorites");
|
||||
assert_eq!(
|
||||
favorites
|
||||
.iter()
|
||||
.map(|favorite| favorite.project_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["newer", "older"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readding_after_remove_gets_a_new_saved_time() {
|
||||
let pool = pool().await;
|
||||
let first = add(
|
||||
ContentFavoriteInput {
|
||||
provider: ContentFavoriteProvider::Modrinth,
|
||||
project_id: "sodium".to_string(),
|
||||
content_type: ContentFavoriteType::Mod,
|
||||
},
|
||||
&pool,
|
||||
)
|
||||
.await
|
||||
.expect("add favorite");
|
||||
remove(ContentFavoriteProvider::Modrinth, "sodium", &pool)
|
||||
.await
|
||||
.expect("remove favorite");
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
let second = add(
|
||||
ContentFavoriteInput {
|
||||
provider: ContentFavoriteProvider::Modrinth,
|
||||
project_id: "sodium".to_string(),
|
||||
content_type: ContentFavoriteType::Mod,
|
||||
},
|
||||
&pool,
|
||||
)
|
||||
.await
|
||||
.expect("re-add favorite");
|
||||
|
||||
assert!(second.saved_at > first.saved_at);
|
||||
}
|
||||
}
|
||||
4713
packages/app-lib/src/state/db.rs
Normal file
4713
packages/app-lib/src/state/db.rs
Normal file
File diff suppressed because it is too large
Load Diff
774
packages/app-lib/src/state/db_backup.rs
Normal file
774
packages/app-lib/src/state/db_backup.rs
Normal file
@ -0,0 +1,774 @@
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteConnection};
|
||||
use sqlx::{ConnectOptions, Connection};
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
const CURRENT_APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const REQUIRED_APP_DB_TABLES: &[&str] =
|
||||
&["_sqlx_migrations", "instances", "settings"];
|
||||
|
||||
enum IntegrityStatus {
|
||||
Healthy,
|
||||
Corrupt(String),
|
||||
}
|
||||
|
||||
pub(crate) async fn restore_corrupt_app_db_if_needed(
|
||||
db_path: &Path,
|
||||
) -> crate::Result<()> {
|
||||
if !db_path.try_exists()? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let backup_dir = app_db_backup_dir_for(db_path)?;
|
||||
restore_corrupt_app_db_from(db_path, &backup_dir).await
|
||||
}
|
||||
|
||||
async fn restore_corrupt_app_db_from(
|
||||
db_path: &Path,
|
||||
backup_dir: &Path,
|
||||
) -> crate::Result<()> {
|
||||
let corruption = match check_database_integrity(db_path).await? {
|
||||
IntegrityStatus::Healthy => return Ok(()),
|
||||
IntegrityStatus::Corrupt(corruption) => corruption,
|
||||
};
|
||||
|
||||
tracing::error!(
|
||||
database = %db_path.display(),
|
||||
corruption,
|
||||
"App database integrity check failed"
|
||||
);
|
||||
|
||||
let Some(backup_path) = latest_healthy_app_db_backup(backup_dir).await?
|
||||
else {
|
||||
return Err(crate::ErrorKind::FSError(format!(
|
||||
"App database {} is corrupted, and no healthy backup is available in {}",
|
||||
db_path.display(),
|
||||
backup_dir.display()
|
||||
))
|
||||
.into());
|
||||
};
|
||||
|
||||
crate::util::io::create_dir_all(backup_dir).await?;
|
||||
let corrupt_path = next_corrupt_database_path(backup_dir).await?;
|
||||
let restore_staging_path = next_restore_staging_path(db_path).await?;
|
||||
|
||||
tokio::fs::copy(&backup_path, &restore_staging_path).await?;
|
||||
if !matches!(
|
||||
check_database_integrity(&restore_staging_path).await?,
|
||||
IntegrityStatus::Healthy
|
||||
) {
|
||||
cleanup_staged_database(&restore_staging_path).await;
|
||||
return Err(crate::ErrorKind::FSError(format!(
|
||||
"App database backup {} became invalid while preparing recovery",
|
||||
backup_path.display()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
if let Err(error) = remove_database_sidecars(&restore_staging_path).await {
|
||||
cleanup_staged_database(&restore_staging_path).await;
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = archive_database_files(db_path, &corrupt_path).await {
|
||||
cleanup_staged_database(&restore_staging_path).await;
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = tokio::fs::rename(&restore_staging_path, db_path).await
|
||||
{
|
||||
let rollback_result =
|
||||
restore_archived_database_files(&corrupt_path, db_path).await;
|
||||
cleanup_staged_database(&restore_staging_path).await;
|
||||
if let Err(rollback_error) = rollback_result {
|
||||
return Err(crate::ErrorKind::FSError(format!(
|
||||
"Failed to activate recovered app database: {error}; failed to restore original database: {rollback_error}"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
database = %db_path.display(),
|
||||
backup = %backup_path.display(),
|
||||
corrupt_archive = %corrupt_path.display(),
|
||||
"Recovered corrupted app database from latest healthy backup"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_database_integrity(
|
||||
db_path: &Path,
|
||||
) -> crate::Result<IntegrityStatus> {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(db_path)
|
||||
.busy_timeout(Duration::from_secs(30))
|
||||
.read_only(true)
|
||||
.create_if_missing(false);
|
||||
let mut conn = match options.connect().await {
|
||||
Ok(conn) => conn,
|
||||
Err(error) if is_sqlite_corruption(&error) => {
|
||||
return Ok(IntegrityStatus::Corrupt(error.to_string()));
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
|
||||
let result = sqlx::query_scalar::<_, String>("PRAGMA quick_check(1)")
|
||||
.fetch_all(&mut conn)
|
||||
.await;
|
||||
let status = match result {
|
||||
Ok(rows) if rows.len() == 1 && rows[0] == "ok" => {
|
||||
IntegrityStatus::Healthy
|
||||
}
|
||||
Ok(rows) => IntegrityStatus::Corrupt(rows.join("; ")),
|
||||
Err(error) if is_sqlite_corruption(&error) => {
|
||||
IntegrityStatus::Corrupt(error.to_string())
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
conn.close().await?;
|
||||
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
fn is_sqlite_corruption(error: &sqlx::Error) -> bool {
|
||||
let sqlx::Error::Database(error) = error else {
|
||||
return false;
|
||||
};
|
||||
error
|
||||
.code()
|
||||
.and_then(|code| code.parse::<i32>().ok())
|
||||
.is_some_and(|code| matches!(code & 0xff, 11 | 26))
|
||||
}
|
||||
|
||||
async fn latest_healthy_app_db_backup(
|
||||
backup_dir: &Path,
|
||||
) -> crate::Result<Option<PathBuf>> {
|
||||
let mut candidates = Vec::new();
|
||||
let mut entries = match tokio::fs::read_dir(backup_dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
let Some(file_name) = path.file_name().and_then(|name| name.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !file_name.starts_with("app-db-before-")
|
||||
|| path.extension().and_then(|extension| extension.to_str())
|
||||
!= Some("db")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let modified = entry.metadata().await?.modified().unwrap_or(UNIX_EPOCH);
|
||||
candidates.push((modified, path));
|
||||
}
|
||||
candidates.sort_by(|left, right| right.0.cmp(&left.0));
|
||||
|
||||
for (_, candidate) in candidates {
|
||||
if matches!(
|
||||
check_database_integrity(&candidate).await,
|
||||
Ok(IntegrityStatus::Healthy)
|
||||
) && is_app_database(&candidate).await?
|
||||
{
|
||||
return Ok(Some(candidate));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn is_app_database(db_path: &Path) -> crate::Result<bool> {
|
||||
let mut conn = open_read_only_db(db_path).await?;
|
||||
let required_tables = serde_json::to_string(REQUIRED_APP_DB_TABLES)?;
|
||||
let table_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name IN (SELECT value FROM json_each(?))",
|
||||
)
|
||||
.bind(required_tables)
|
||||
.fetch_one(&mut conn)
|
||||
.await?;
|
||||
conn.close().await?;
|
||||
|
||||
Ok(table_count == REQUIRED_APP_DB_TABLES.len() as i64)
|
||||
}
|
||||
|
||||
async fn archive_database_files(
|
||||
db_path: &Path,
|
||||
archive_path: &Path,
|
||||
) -> crate::Result<()> {
|
||||
tokio::fs::rename(db_path, archive_path).await?;
|
||||
let mut archived_sidecars = Vec::new();
|
||||
|
||||
for suffix in ["-wal", "-shm"] {
|
||||
let source = sqlite_sidecar_path(db_path, suffix);
|
||||
if !source.try_exists()? {
|
||||
continue;
|
||||
}
|
||||
let destination = sqlite_sidecar_path(archive_path, suffix);
|
||||
if let Err(error) = tokio::fs::rename(&source, &destination).await {
|
||||
for (archived, original) in archived_sidecars.into_iter().rev() {
|
||||
let _ = tokio::fs::rename(archived, original).await;
|
||||
}
|
||||
let _ = tokio::fs::rename(archive_path, db_path).await;
|
||||
return Err(error.into());
|
||||
}
|
||||
archived_sidecars.push((destination, source));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_archived_database_files(
|
||||
archive_path: &Path,
|
||||
db_path: &Path,
|
||||
) -> crate::Result<()> {
|
||||
tokio::fs::rename(archive_path, db_path).await?;
|
||||
for suffix in ["-wal", "-shm"] {
|
||||
let archived = sqlite_sidecar_path(archive_path, suffix);
|
||||
if archived.try_exists()? {
|
||||
tokio::fs::rename(archived, sqlite_sidecar_path(db_path, suffix))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn next_corrupt_database_path(
|
||||
backup_dir: &Path,
|
||||
) -> crate::Result<PathBuf> {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
for suffix in 1.. {
|
||||
let suffix = (suffix > 1).then(|| format!("-{suffix}"));
|
||||
let path = backup_dir.join(format!(
|
||||
"app-db-corrupt-{timestamp}{}.db",
|
||||
suffix.as_deref().unwrap_or_default()
|
||||
));
|
||||
if !path.try_exists()? {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
async fn next_restore_staging_path(db_path: &Path) -> crate::Result<PathBuf> {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
for suffix in 1.. {
|
||||
let suffix = (suffix > 1).then(|| format!("-{suffix}"));
|
||||
let path = db_path.with_file_name(format!(
|
||||
"app.db.restore-{timestamp}{}.tmp",
|
||||
suffix.as_deref().unwrap_or_default()
|
||||
));
|
||||
if !path.try_exists()? {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
fn sqlite_sidecar_path(db_path: &Path, suffix: &str) -> PathBuf {
|
||||
let mut path = OsString::from(db_path.as_os_str());
|
||||
path.push(suffix);
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
async fn remove_database_sidecars(db_path: &Path) -> crate::Result<()> {
|
||||
for suffix in ["-wal", "-shm"] {
|
||||
let path = sqlite_sidecar_path(db_path, suffix);
|
||||
match tokio::fs::remove_file(path).await {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cleanup_staged_database(db_path: &Path) {
|
||||
let _ = tokio::fs::remove_file(db_path).await;
|
||||
let _ = remove_database_sidecars(db_path).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_backup_existing_app_db(
|
||||
db_path: &Path,
|
||||
) -> crate::Result<()> {
|
||||
if !db_path.try_exists()? {
|
||||
tracing::debug!(
|
||||
"Skipping pre-migration app database backup because {} does not exist",
|
||||
db_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"Inspecting {} for a pre-migration app database backup",
|
||||
db_path.display()
|
||||
);
|
||||
|
||||
let mut conn = match open_read_only_db(db_path).await {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Failed to open {} read-only before migrations: {err}",
|
||||
db_path.display()
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
let has_user_tables = match has_user_tables(&mut conn).await {
|
||||
Ok(has_user_tables) => has_user_tables,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Failed to inspect app database tables before migrations: {err}"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
if !has_user_tables {
|
||||
tracing::debug!(
|
||||
"Skipping pre-migration app database backup because {} has no app data tables",
|
||||
db_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stored_version = match read_stored_app_version(&mut conn).await {
|
||||
Ok(version) => version,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Failed to read stored app database version before migrations: {err}"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
if stored_version.as_deref() == Some(CURRENT_APP_VERSION) {
|
||||
tracing::debug!(
|
||||
"Skipping pre-migration app database backup because app version is already recorded as {CURRENT_APP_VERSION}"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stored_version = stored_version.as_deref().unwrap_or("unknown");
|
||||
let backup_dir = match app_db_backup_dir_for(db_path) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Failed to resolve app database backup directory before migrations: {err}"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let backup_path = match next_backup_path(
|
||||
&backup_dir,
|
||||
stored_version,
|
||||
CURRENT_APP_VERSION,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Failed to choose app database backup path in {} before migrations: {err}",
|
||||
backup_dir.display()
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"Creating pre-migration app database backup from version {stored_version} before opening with version {CURRENT_APP_VERSION} at {}",
|
||||
backup_path.display()
|
||||
);
|
||||
|
||||
if let Err(err) = create_sqlite_snapshot(&mut conn, &backup_path).await {
|
||||
tracing::error!(
|
||||
"Failed to create pre-migration app database backup at {}: {err}",
|
||||
backup_path.display()
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Created pre-migration app database backup at {}",
|
||||
backup_path.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn backup_app_db_for_update(
|
||||
db_path: &Path,
|
||||
target_version: &str,
|
||||
) -> crate::Result<PathBuf> {
|
||||
if !db_path.try_exists()? {
|
||||
return Err(crate::ErrorKind::FSError(format!(
|
||||
"Cannot back up missing app database {}",
|
||||
db_path.display()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
let channel = db_path
|
||||
.parent()
|
||||
.and_then(Path::file_name)
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("release");
|
||||
let settings_dir =
|
||||
db_path.parent().and_then(Path::parent).ok_or_else(|| {
|
||||
crate::ErrorKind::FSError(format!(
|
||||
"App database path {} has no settings directory",
|
||||
db_path.display()
|
||||
))
|
||||
})?;
|
||||
let backup_dir = settings_dir.join("Backups").join("app-db").join(channel);
|
||||
crate::util::io::create_dir_all(&backup_dir).await?;
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let backup_path = backup_dir.join(format!(
|
||||
"app-db-before-update-{}-{}-{timestamp}.db",
|
||||
sanitize_version_for_filename(channel),
|
||||
sanitize_version_for_filename(target_version),
|
||||
));
|
||||
|
||||
let mut conn = open_read_only_db(db_path).await?;
|
||||
create_sqlite_snapshot(&mut conn, &backup_path).await?;
|
||||
conn.close().await?;
|
||||
if !matches!(
|
||||
check_database_integrity(&backup_path).await?,
|
||||
IntegrityStatus::Healthy
|
||||
) {
|
||||
let _ = tokio::fs::remove_file(&backup_path).await;
|
||||
return Err(crate::ErrorKind::FSError(format!(
|
||||
"Update database backup {} failed its integrity check",
|
||||
backup_path.display()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
database = %db_path.display(),
|
||||
backup = %backup_path.display(),
|
||||
target_version,
|
||||
"Created app database backup before update"
|
||||
);
|
||||
Ok(backup_path)
|
||||
}
|
||||
|
||||
async fn open_read_only_db(db_path: &Path) -> crate::Result<SqliteConnection> {
|
||||
let conn_options = SqliteConnectOptions::new()
|
||||
.filename(db_path)
|
||||
.busy_timeout(Duration::from_secs(30))
|
||||
.read_only(true)
|
||||
.create_if_missing(false);
|
||||
|
||||
Ok(conn_options.connect().await?)
|
||||
}
|
||||
|
||||
pub fn app_db_backup_dir() -> crate::Result<PathBuf> {
|
||||
if let Some(path) = std::env::var_os("THESEUS_DB_BACKUP_DIR") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
|
||||
let app_identifier = if let Some(dir_info) =
|
||||
crate::state::DirectoryInfo::global_handle_if_ready()
|
||||
{
|
||||
dir_info.app_identifier.clone()
|
||||
} else {
|
||||
crate::brand::BUNDLE_IDENTIFIER.to_string()
|
||||
};
|
||||
|
||||
let base =
|
||||
crate::state::DirectoryInfo::initial_settings_dir_path(&app_identifier)
|
||||
.ok_or(crate::ErrorKind::FSError(
|
||||
"Could not find valid config dir for app database backups"
|
||||
.to_string(),
|
||||
))?;
|
||||
|
||||
Ok(base.join("Backups").join("app-db"))
|
||||
}
|
||||
|
||||
fn app_db_backup_dir_for(db_path: &Path) -> crate::Result<PathBuf> {
|
||||
if let Some(path) = std::env::var_os("THESEUS_DB_BACKUP_DIR") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
|
||||
let base = db_path.parent().ok_or_else(|| {
|
||||
crate::ErrorKind::FSError(format!(
|
||||
"App database path {} has no parent directory",
|
||||
db_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
let backup_dir = base.join("Backups").join("app-db");
|
||||
match db_path
|
||||
.parent()
|
||||
.and_then(Path::file_name)
|
||||
.and_then(|name| name.to_str())
|
||||
{
|
||||
Some("beta") | Some("release") => Ok(backup_dir.join(
|
||||
db_path
|
||||
.parent()
|
||||
.and_then(Path::file_name)
|
||||
.expect("database channel directory has a name"),
|
||||
)),
|
||||
_ => Ok(backup_dir),
|
||||
}
|
||||
}
|
||||
|
||||
async fn has_user_tables(conn: &mut SqliteConnection) -> crate::Result<bool> {
|
||||
let count = sqlx::query_scalar!(
|
||||
"
|
||||
SELECT COUNT(*)
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
AND name NOT IN ('_sqlx_migrations', 'app_metadata')
|
||||
",
|
||||
)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn read_stored_app_version(
|
||||
conn: &mut SqliteConnection,
|
||||
) -> crate::Result<Option<String>> {
|
||||
if !has_table(conn, "app_metadata").await? {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(sqlx::query_scalar!(
|
||||
"SELECT value FROM app_metadata WHERE key = 'app_version'"
|
||||
)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn has_table(
|
||||
conn: &mut SqliteConnection,
|
||||
table_name: &str,
|
||||
) -> crate::Result<bool> {
|
||||
let count = sqlx::query_scalar!(
|
||||
"
|
||||
SELECT COUNT(*)
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name = ?
|
||||
",
|
||||
table_name,
|
||||
)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn next_backup_path(
|
||||
backup_dir: &Path,
|
||||
stored_version: &str,
|
||||
current_version: &str,
|
||||
) -> crate::Result<PathBuf> {
|
||||
crate::util::io::create_dir_all(backup_dir).await?;
|
||||
|
||||
let stored_version = sanitize_version_for_filename(stored_version);
|
||||
let current_version = sanitize_version_for_filename(current_version);
|
||||
|
||||
let backup_path = backup_dir.join(format!(
|
||||
"app-db-before-{current_version}-from-{stored_version}.db"
|
||||
));
|
||||
if !backup_path.try_exists()? {
|
||||
return Ok(backup_path);
|
||||
}
|
||||
|
||||
for suffix in 2.. {
|
||||
let backup_path = backup_dir.join(format!(
|
||||
"app-db-before-{current_version}-from-{stored_version}-{suffix}.db"
|
||||
));
|
||||
if !backup_path.try_exists()? {
|
||||
return Ok(backup_path);
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
fn sanitize_version_for_filename(version: &str) -> String {
|
||||
let mut sanitized = String::new();
|
||||
let mut replaced_last_char = false;
|
||||
|
||||
for character in version.chars() {
|
||||
if character.is_ascii_alphanumeric()
|
||||
|| character == '.'
|
||||
|| character == '-'
|
||||
|| character == '_'
|
||||
{
|
||||
sanitized.push(character);
|
||||
replaced_last_char = false;
|
||||
} else if !replaced_last_char {
|
||||
sanitized.push('-');
|
||||
replaced_last_char = true;
|
||||
}
|
||||
}
|
||||
|
||||
let sanitized = sanitized.trim_matches(&['.', '-', '_'][..]);
|
||||
if sanitized.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
sanitized.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_sqlite_snapshot(
|
||||
conn: &mut SqliteConnection,
|
||||
backup_path: &Path,
|
||||
) -> crate::Result<()> {
|
||||
let backup_path = backup_path
|
||||
.to_str()
|
||||
.ok_or_else(|| crate::ErrorKind::UTFError(backup_path.to_path_buf()))?;
|
||||
|
||||
sqlx::query!("VACUUM INTO ?", backup_path)
|
||||
.execute(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn create_test_app_db(path: &Path, marker: &str) {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true);
|
||||
let mut conn = options.connect().await.unwrap();
|
||||
sqlx::raw_sql(
|
||||
"CREATE TABLE _sqlx_migrations (version INTEGER PRIMARY KEY);
|
||||
CREATE TABLE instances (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE settings (id INTEGER PRIMARY KEY);
|
||||
CREATE TABLE recovery_marker (value TEXT NOT NULL);",
|
||||
)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO recovery_marker (value) VALUES (?)")
|
||||
.bind(marker)
|
||||
.execute(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
conn.close().await.unwrap();
|
||||
}
|
||||
|
||||
async fn read_marker(path: &Path) -> String {
|
||||
let mut conn = open_read_only_db(path).await.unwrap();
|
||||
let marker = sqlx::query_scalar("SELECT value FROM recovery_marker")
|
||||
.fetch_one(&mut conn)
|
||||
.await
|
||||
.unwrap();
|
||||
conn.close().await.unwrap();
|
||||
marker
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthy_database_is_not_replaced() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let db_path = temp.path().join("app.db");
|
||||
let backup_dir = temp.path().join("Backups").join("app-db");
|
||||
tokio::fs::create_dir_all(&backup_dir).await.unwrap();
|
||||
create_test_app_db(&db_path, "current").await;
|
||||
create_test_app_db(
|
||||
&backup_dir.join("app-db-before-test-from-old.db"),
|
||||
"backup",
|
||||
)
|
||||
.await;
|
||||
|
||||
restore_corrupt_app_db_from(&db_path, &backup_dir)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(read_marker(&db_path).await, "current");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrupted_database_restores_latest_healthy_backup() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let db_path = temp.path().join("app.db");
|
||||
let backup_dir = temp.path().join("Backups").join("app-db");
|
||||
tokio::fs::create_dir_all(&backup_dir).await.unwrap();
|
||||
tokio::fs::write(&db_path, b"not a sqlite database")
|
||||
.await
|
||||
.unwrap();
|
||||
create_test_app_db(
|
||||
&backup_dir.join("app-db-before-test-from-old.db"),
|
||||
"backup",
|
||||
)
|
||||
.await;
|
||||
|
||||
restore_corrupt_app_db_from(&db_path, &backup_dir)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(read_marker(&db_path).await, "backup");
|
||||
let mut entries = tokio::fs::read_dir(&backup_dir).await.unwrap();
|
||||
let mut found_corrupt_archive = false;
|
||||
while let Some(entry) = entries.next_entry().await.unwrap() {
|
||||
found_corrupt_archive |= entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|name| name.starts_with("app-db-corrupt-"));
|
||||
}
|
||||
assert!(found_corrupt_archive);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrupted_database_without_backup_is_left_untouched() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let db_path = temp.path().join("app.db");
|
||||
let backup_dir = temp.path().join("Backups").join("app-db");
|
||||
let corrupt_bytes = b"not a sqlite database";
|
||||
tokio::fs::write(&db_path, corrupt_bytes).await.unwrap();
|
||||
|
||||
let error = restore_corrupt_app_db_from(&db_path, &backup_dir)
|
||||
.await
|
||||
.expect_err("recovery must require a healthy backup");
|
||||
|
||||
assert!(error.to_string().contains("no healthy backup"));
|
||||
assert_eq!(tokio::fs::read(&db_path).await.unwrap(), corrupt_bytes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_backup_creates_a_healthy_channel_snapshot() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let db_path = temp.path().join("release").join("app.db");
|
||||
tokio::fs::create_dir_all(db_path.parent().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
create_test_app_db(&db_path, "release").await;
|
||||
|
||||
let backup_path =
|
||||
backup_app_db_for_update(&db_path, "1.10.0").await.unwrap();
|
||||
|
||||
assert!(backup_path.exists());
|
||||
assert_eq!(read_marker(&backup_path).await, "release");
|
||||
assert!(matches!(
|
||||
check_database_integrity(&backup_path).await.unwrap(),
|
||||
IntegrityStatus::Healthy
|
||||
));
|
||||
}
|
||||
}
|
||||
715
packages/app-lib/src/state/dirs.rs
Normal file
715
packages/app-lib/src/state/dirs.rs
Normal file
@ -0,0 +1,715 @@
|
||||
//! Theseus directory information
|
||||
use crate::LoadingBarType;
|
||||
use crate::event::emit::{emit_loading, init_loading};
|
||||
use crate::state::LAUNCHER_STATE;
|
||||
use crate::state::{JavaVersion, Settings};
|
||||
use crate::util::fetch::IoSemaphore;
|
||||
use dashmap::DashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
|
||||
pub const CACHES_FOLDER_NAME: &str = "caches";
|
||||
pub const LAUNCHER_LOGS_FOLDER_NAME: &str = "launcher_logs";
|
||||
pub const INSTANCES_FOLDER_NAME: &str = "profiles";
|
||||
pub const SERVERS_FOLDER_NAME: &str = "servers";
|
||||
pub const INSTALL_ROLLBACKS_FOLDER_NAME: &str = "install-rollbacks";
|
||||
pub const METADATA_FOLDER_NAME: &str = "meta";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DirectoryInfo {
|
||||
pub settings_dir: PathBuf, // Base settings directory- app database
|
||||
pub config_dir: PathBuf, // Base config directory- instances, minecraft downloads, etc. Changeable as a setting.
|
||||
pub app_identifier: String,
|
||||
}
|
||||
|
||||
impl DirectoryInfo {
|
||||
pub fn global_handle_if_ready() -> Option<&'static Self> {
|
||||
LAUNCHER_STATE.get().map(|x| &x.directories)
|
||||
}
|
||||
|
||||
pub fn get_initial_settings_dir(&self) -> Option<PathBuf> {
|
||||
Self::initial_settings_dir_path(&self.app_identifier)
|
||||
}
|
||||
|
||||
// Get the settings directory
|
||||
// init() is not needed for this function
|
||||
pub fn initial_settings_dir_path(app_identifier: &str) -> Option<PathBuf> {
|
||||
Self::env_path("THESEUS_CONFIG_DIR")
|
||||
.or_else(|| Some(dirs::data_dir()?.join(app_identifier)))
|
||||
}
|
||||
|
||||
/// Get all paths needed for Theseus to operate properly
|
||||
#[tracing::instrument]
|
||||
pub async fn init(
|
||||
config_dir: Option<String>,
|
||||
app_identifier: &str,
|
||||
) -> crate::Result<Self> {
|
||||
let settings_dir = Self::initial_settings_dir_path(app_identifier)
|
||||
.ok_or(crate::ErrorKind::FSError(
|
||||
"Could not find valid settings dir".to_string(),
|
||||
))?;
|
||||
|
||||
fs::create_dir_all(&settings_dir).await.map_err(|err| {
|
||||
crate::ErrorKind::FSError(format!(
|
||||
"Error creating Theseus config directory: {err}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let config_dir =
|
||||
config_dir.map_or_else(|| settings_dir.clone(), PathBuf::from);
|
||||
|
||||
Ok(Self {
|
||||
settings_dir,
|
||||
config_dir,
|
||||
app_identifier: app_identifier.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the Minecraft instance metadata directory
|
||||
#[inline]
|
||||
pub fn metadata_dir(&self) -> PathBuf {
|
||||
self.config_dir.join(METADATA_FOLDER_NAME)
|
||||
}
|
||||
|
||||
/// Get the Minecraft java versions metadata directory
|
||||
#[inline]
|
||||
pub fn java_versions_dir(&self) -> PathBuf {
|
||||
self.metadata_dir().join("java_versions")
|
||||
}
|
||||
|
||||
/// Get the Minecraft versions metadata directory
|
||||
#[inline]
|
||||
pub fn versions_dir(&self) -> PathBuf {
|
||||
self.metadata_dir().join("versions")
|
||||
}
|
||||
|
||||
/// Get the metadata directory for a given version
|
||||
#[inline]
|
||||
pub fn version_dir(&self, version: &str) -> PathBuf {
|
||||
self.versions_dir().join(version)
|
||||
}
|
||||
|
||||
/// Get the Minecraft libraries metadata directory
|
||||
#[inline]
|
||||
pub fn libraries_dir(&self) -> PathBuf {
|
||||
self.metadata_dir().join("libraries")
|
||||
}
|
||||
|
||||
/// Get the Minecraft assets metadata directory
|
||||
#[inline]
|
||||
pub fn assets_dir(&self) -> PathBuf {
|
||||
self.metadata_dir().join("assets")
|
||||
}
|
||||
|
||||
/// Get the assets index directory
|
||||
#[inline]
|
||||
pub fn assets_index_dir(&self) -> PathBuf {
|
||||
self.assets_dir().join("indexes")
|
||||
}
|
||||
|
||||
/// Get the assets objects directory
|
||||
#[inline]
|
||||
pub fn objects_dir(&self) -> PathBuf {
|
||||
self.assets_dir().join("objects")
|
||||
}
|
||||
|
||||
/// Get the directory for a specific object
|
||||
#[inline]
|
||||
pub fn object_dir(&self, hash: &str) -> PathBuf {
|
||||
self.objects_dir().join(&hash[..2]).join(hash)
|
||||
}
|
||||
|
||||
/// Get the Minecraft log config's directory
|
||||
#[inline]
|
||||
pub fn log_configs_dir(&self) -> PathBuf {
|
||||
self.metadata_dir().join("log_configs")
|
||||
}
|
||||
|
||||
/// Get the Minecraft legacy assets metadata directory
|
||||
#[inline]
|
||||
pub fn legacy_assets_dir(&self) -> PathBuf {
|
||||
self.metadata_dir().join("resources")
|
||||
}
|
||||
|
||||
/// Get the Minecraft legacy assets metadata directory
|
||||
#[inline]
|
||||
pub fn natives_dir(&self) -> PathBuf {
|
||||
self.metadata_dir().join("natives")
|
||||
}
|
||||
|
||||
/// Get the natives directory for a version of Minecraft
|
||||
#[inline]
|
||||
pub fn version_natives_dir(&self, version: &str) -> PathBuf {
|
||||
self.natives_dir().join(version)
|
||||
}
|
||||
|
||||
/// Get the directory containing instance icons
|
||||
#[inline]
|
||||
pub fn icon_dir(&self) -> PathBuf {
|
||||
self.config_dir.join("icons")
|
||||
}
|
||||
|
||||
/// Get the instances directory
|
||||
#[inline]
|
||||
pub fn instances_dir(&self) -> PathBuf {
|
||||
self.config_dir.join(INSTANCES_FOLDER_NAME)
|
||||
}
|
||||
|
||||
/// Get the directory containing managed dedicated servers
|
||||
#[inline]
|
||||
pub fn servers_dir(&self) -> PathBuf {
|
||||
self.config_dir.join(SERVERS_FOLDER_NAME)
|
||||
}
|
||||
|
||||
/// Gets the directory of a managed dedicated server by id
|
||||
#[inline]
|
||||
pub fn server_dir(&self, server_id: &str) -> PathBuf {
|
||||
self.servers_dir().join(server_id)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn install_rollbacks_dir(&self) -> PathBuf {
|
||||
self.config_dir.join(INSTALL_ROLLBACKS_FOLDER_NAME)
|
||||
}
|
||||
|
||||
/// Gets the logs dir for a given instance path
|
||||
#[inline]
|
||||
pub fn instance_logs_dir(&self, instance_path: &str) -> PathBuf {
|
||||
self.instances_dir().join(instance_path).join("logs")
|
||||
}
|
||||
|
||||
/// Gets the logs dir for a resolved game directory (honours a per-instance
|
||||
/// `game_dir_override`), so launcher-captured logs follow the game dir.
|
||||
#[inline]
|
||||
pub fn game_logs_dir(&self, game_dir: &std::path::Path) -> PathBuf {
|
||||
game_dir.join("logs")
|
||||
}
|
||||
|
||||
/// Gets the crash reports dir for a given instance path
|
||||
#[inline]
|
||||
pub fn crash_reports_dir(&self, instance_path: &str) -> PathBuf {
|
||||
self.instances_dir()
|
||||
.join(instance_path)
|
||||
.join("crash-reports")
|
||||
}
|
||||
|
||||
/// Gets the crash reports dir for a resolved game directory (honours a
|
||||
/// per-instance `game_dir_override`), so game-written crash reports are
|
||||
/// read from the same place the game writes them.
|
||||
#[inline]
|
||||
pub fn game_crash_reports_dir(
|
||||
&self,
|
||||
game_dir: &std::path::Path,
|
||||
) -> PathBuf {
|
||||
game_dir.join("crash-reports")
|
||||
}
|
||||
|
||||
/// Resolve the game working directory (the "content" directory the game
|
||||
/// actually reads and writes: mods, saves, config, logs, crash-reports,
|
||||
/// options.txt, resourcepacks, datapacks, shaders, worlds) for an instance.
|
||||
///
|
||||
/// Returns the per-instance override when set (an external /
|
||||
/// non-version-isolated folder), otherwise the managed folder under the
|
||||
/// instances directory.
|
||||
///
|
||||
/// This is the SOLE resolver for anything that represents the game's own
|
||||
/// content. Launcher-owned bookkeeping (instance config file, icon, install
|
||||
/// rollbacks, content-backup metadata) must keep using `instances_dir()` and
|
||||
/// not go through this function.
|
||||
pub fn resolve_game_dir(
|
||||
&self,
|
||||
instance_path: &str,
|
||||
game_dir_override: Option<&str>,
|
||||
) -> PathBuf {
|
||||
match game_dir_override {
|
||||
// The override must be an absolute path; a relative override would
|
||||
// make the game's working directory depend on the process cwd, so
|
||||
// treat it as unset and fall back to the managed folder.
|
||||
Some(override_dir)
|
||||
if !override_dir.is_empty()
|
||||
&& (Path::new(override_dir).is_absolute()
|
||||
|| Self::is_absolute_override(override_dir)) =>
|
||||
{
|
||||
PathBuf::from(override_dir)
|
||||
}
|
||||
_ => self.instances_dir().join(instance_path),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: `resolve_game_dir` for an `Instance`, borrowing its
|
||||
/// relative `path` and optional `game_dir_override`.
|
||||
pub fn instance_game_dir(
|
||||
&self,
|
||||
instance: &crate::state::instances::Instance,
|
||||
) -> PathBuf {
|
||||
// Symlink imports persist the exact external target independently of
|
||||
// the optional game-dir override. Use it as a fallback so content
|
||||
// management keeps operating on the real directory even for older
|
||||
// imports that predate the override field.
|
||||
let game_dir = instance
|
||||
.game_dir_override
|
||||
.as_deref()
|
||||
.or(instance.symlink_target.as_deref());
|
||||
self.resolve_game_dir(&instance.path, game_dir)
|
||||
}
|
||||
|
||||
/// `Path::is_absolute` treats a Windows drive-letter path (e.g.
|
||||
/// `D:\Games\.minecraft`) as relative on non-Windows builds. Overrides are
|
||||
/// persisted and round-trip across OSes, so a drive-letter path must be
|
||||
/// honored as absolute on every platform.
|
||||
fn is_absolute_override(value: &str) -> bool {
|
||||
let bytes = value.as_bytes();
|
||||
bytes.len() >= 3
|
||||
&& bytes[0].is_ascii_alphabetic()
|
||||
&& bytes[1] == b':'
|
||||
&& matches!(bytes[2], b'/' | b'\\')
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn launcher_logs_dir(&self) -> Option<PathBuf> {
|
||||
self.get_initial_settings_dir()
|
||||
.map(|d| d.join(LAUNCHER_LOGS_FOLDER_NAME))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn launcher_logs_dir_path(app_identifier: &str) -> Option<PathBuf> {
|
||||
Self::initial_settings_dir_path(app_identifier)
|
||||
.map(|d| d.join(LAUNCHER_LOGS_FOLDER_NAME))
|
||||
}
|
||||
|
||||
/// Get the cache directory for Theseus
|
||||
#[inline]
|
||||
pub fn caches_dir(&self) -> PathBuf {
|
||||
self.config_dir.join(CACHES_FOLDER_NAME)
|
||||
}
|
||||
|
||||
/// Get path from environment variable
|
||||
#[inline]
|
||||
fn env_path(name: &str) -> Option<PathBuf> {
|
||||
std::env::var_os(name).map(PathBuf::from)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(settings, exec, io_semaphore))]
|
||||
pub async fn move_launcher_directory<'a, E>(
|
||||
settings: &mut Settings,
|
||||
exec: E,
|
||||
io_semaphore: &IoSemaphore,
|
||||
app_identifier: &str,
|
||||
) -> crate::Result<()>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Sqlite> + Copy,
|
||||
{
|
||||
let app_dir = DirectoryInfo::initial_settings_dir_path(app_identifier)
|
||||
.ok_or(crate::ErrorKind::FSError(
|
||||
"Could not find valid config dir".to_string(),
|
||||
))?;
|
||||
|
||||
if let Some(ref prev_custom_dir) = settings.prev_custom_dir {
|
||||
let prev_dir = PathBuf::from(prev_custom_dir);
|
||||
|
||||
let move_dir = settings
|
||||
.custom_dir
|
||||
.as_ref()
|
||||
.map_or_else(|| app_dir.clone(), PathBuf::from);
|
||||
|
||||
async fn is_dir_writable(
|
||||
new_config_dir: &Path,
|
||||
) -> crate::Result<bool> {
|
||||
let temp_path = new_config_dir.join(".tmp");
|
||||
match fs::write(temp_path.clone(), "test").await {
|
||||
Ok(_) => {
|
||||
fs::remove_file(temp_path).await?;
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Error writing to new config dir: {}",
|
||||
e
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_disk_usage(path: &Path) -> crate::Result<Option<u64>> {
|
||||
let path = crate::util::io::canonicalize(path)?;
|
||||
|
||||
let disks = sysinfo::Disks::new_with_refreshed_list();
|
||||
|
||||
for disk in &disks {
|
||||
if path.starts_with(disk.mount_point()) {
|
||||
return Ok(Some(disk.available_space()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
let new_dir = move_dir.to_string_lossy().to_string();
|
||||
|
||||
if prev_dir != move_dir {
|
||||
let loader_bar_id = init_loading(
|
||||
LoadingBarType::DirectoryMove {
|
||||
old: prev_dir.clone(),
|
||||
new: move_dir.clone(),
|
||||
},
|
||||
100.0,
|
||||
"Moving launcher directory",
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !is_dir_writable(&move_dir).await? {
|
||||
return Err(crate::ErrorKind::DirectoryMoveError(format!("Cannot move directory to {}: directory is not writable", move_dir.display())).into());
|
||||
}
|
||||
|
||||
const MOVE_DIRS: &[&str] = &[
|
||||
CACHES_FOLDER_NAME,
|
||||
INSTANCES_FOLDER_NAME,
|
||||
METADATA_FOLDER_NAME,
|
||||
];
|
||||
|
||||
struct MovePath {
|
||||
old: PathBuf,
|
||||
new: PathBuf,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
async fn add_paths(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
paths: &mut Vec<MovePath>,
|
||||
total_size: &mut u64,
|
||||
) -> crate::Result<()> {
|
||||
if !source.exists() {
|
||||
crate::util::io::create_dir_all(source).await?;
|
||||
}
|
||||
|
||||
if !destination.exists() {
|
||||
crate::util::io::create_dir_all(destination).await?;
|
||||
}
|
||||
|
||||
for entry_path in
|
||||
crate::pack::import::get_all_subfiles(source, false)
|
||||
.await?
|
||||
{
|
||||
let relative_path = entry_path.strip_prefix(source)?;
|
||||
let new_path = destination.join(relative_path);
|
||||
let path_size =
|
||||
entry_path.metadata().map(|x| x.len()).unwrap_or(0);
|
||||
|
||||
*total_size += path_size;
|
||||
|
||||
paths.push(MovePath {
|
||||
old: entry_path,
|
||||
new: new_path,
|
||||
size: path_size,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let mut paths: Vec<MovePath> = vec![];
|
||||
let mut total_size = 0;
|
||||
|
||||
for dir in MOVE_DIRS {
|
||||
add_paths(
|
||||
&prev_dir.join(dir),
|
||||
&move_dir.join(dir),
|
||||
&mut paths,
|
||||
&mut total_size,
|
||||
)
|
||||
.await?;
|
||||
emit_loading(
|
||||
&loader_bar_id,
|
||||
10.0 / (MOVE_DIRS.len() as f64),
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
|
||||
let paths_len = paths.len();
|
||||
|
||||
if crate::util::io::is_same_disk(&prev_dir, &move_dir)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let success_idxs = Arc::new(DashSet::new());
|
||||
|
||||
let loader_bar_id = Arc::new(&loader_bar_id);
|
||||
let res =
|
||||
futures::future::try_join_all(paths.iter().enumerate().map(|(idx, x)| {
|
||||
let loader_bar_id = loader_bar_id.clone();
|
||||
let success_idxs = success_idxs.clone();
|
||||
|
||||
async move {
|
||||
let _permit = io_semaphore.0.acquire().await?;
|
||||
|
||||
if let Some(parent) = x.new.parent() {
|
||||
crate::util::io::create_dir_all(parent).await.map_err(|e| {
|
||||
crate::Error::from(crate::ErrorKind::DirectoryMoveError(
|
||||
format!(
|
||||
"Failed to create directory {}: {}",
|
||||
parent.display(),
|
||||
e
|
||||
)
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
crate::util::io::rename_or_move(
|
||||
&x.old,
|
||||
&x.new,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::Error::from(crate::ErrorKind::DirectoryMoveError(
|
||||
format!(
|
||||
"Failed to move directory from {} to {}: {e:?}",
|
||||
x.old.display(),
|
||||
x.new.display(),
|
||||
),
|
||||
))
|
||||
})?;
|
||||
|
||||
let _ = emit_loading(
|
||||
&loader_bar_id,
|
||||
90.0 / paths_len as f64,
|
||||
None,
|
||||
);
|
||||
|
||||
success_idxs.insert(idx);
|
||||
|
||||
Ok::<(), crate::Error>(())
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
|
||||
if let Err(e) = res {
|
||||
for idx in success_idxs.iter() {
|
||||
let path = &paths[*idx.key()];
|
||||
|
||||
let res =
|
||||
tokio::fs::rename(&path.new, &path.old).await;
|
||||
|
||||
if let Err(e) = res {
|
||||
tracing::warn!(
|
||||
"Failed to rollback directory {}: {}",
|
||||
path.new.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Err(e);
|
||||
}
|
||||
} else {
|
||||
if let Some(disk_usage) = get_disk_usage(&move_dir)?
|
||||
&& total_size > disk_usage
|
||||
{
|
||||
return Err(crate::ErrorKind::DirectoryMoveError(format!("Not enough space to move directory to {}: only {} bytes available", app_dir.display(), disk_usage)).into());
|
||||
}
|
||||
|
||||
let loader_bar_id = Arc::new(&loader_bar_id);
|
||||
futures::future::try_join_all(paths.iter().map(|x| {
|
||||
let loader_bar_id = loader_bar_id.clone();
|
||||
|
||||
async move {
|
||||
crate::util::fetch::copy(
|
||||
&x.old,
|
||||
&x.new,
|
||||
io_semaphore,
|
||||
)
|
||||
.await.map_err(|e| { crate::Error::from(
|
||||
crate::ErrorKind::DirectoryMoveError(format!("Failed to move directory from {} to {}: {e:?}", x.old.display(), x.new.display())))
|
||||
})?;
|
||||
|
||||
let _ = emit_loading(
|
||||
&loader_bar_id,
|
||||
((x.size as f64) / (total_size as f64)) * 60.0,
|
||||
None,
|
||||
);
|
||||
|
||||
Ok::<(), crate::Error>(())
|
||||
}
|
||||
}))
|
||||
.await?;
|
||||
|
||||
futures::future::join_all(paths.iter().map(|x| {
|
||||
let loader_bar_id = loader_bar_id.clone();
|
||||
|
||||
async move {
|
||||
let res = async {
|
||||
let _permit = io_semaphore.0.acquire().await?;
|
||||
crate::util::io::remove_file(&x.old).await?;
|
||||
|
||||
emit_loading(
|
||||
&loader_bar_id,
|
||||
30.0 / paths_len as f64,
|
||||
None,
|
||||
)?;
|
||||
|
||||
Ok::<(), crate::Error>(())
|
||||
};
|
||||
|
||||
if let Err(e) = res.await {
|
||||
tracing::warn!(
|
||||
"Failed to remove old file {}: {}",
|
||||
x.old.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
let java_versions = JavaVersion::get_all(exec).await?;
|
||||
for java_version in java_versions {
|
||||
let new_java_path = java_version.path.replace(
|
||||
prev_custom_dir,
|
||||
new_dir.trim_end_matches('/').trim_end_matches('\\'),
|
||||
);
|
||||
if crate::util::jre::is_java_install_staging_path(
|
||||
Path::new(&new_java_path),
|
||||
) {
|
||||
tracing::warn!(
|
||||
java = %new_java_path,
|
||||
"Dropping incomplete Java installation during directory migration"
|
||||
);
|
||||
JavaVersion::delete(&java_version.path, exec).await?;
|
||||
continue;
|
||||
}
|
||||
if new_java_path != java_version.path {
|
||||
JavaVersion::update_path(
|
||||
&java_version.path,
|
||||
&new_java_path,
|
||||
exec,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
sqlx::query(
|
||||
"
|
||||
UPDATE discovered_javas
|
||||
SET path = replace(path, $1, $2)
|
||||
WHERE path LIKE $1 || '%'
|
||||
",
|
||||
)
|
||||
.bind(prev_custom_dir)
|
||||
.bind(new_dir.trim_end_matches('/').trim_end_matches('\\'))
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
let new_dir = new_dir
|
||||
.trim_end_matches('/')
|
||||
.trim_end_matches('\\')
|
||||
.to_string();
|
||||
let new_dir = new_dir.as_str();
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instances
|
||||
SET icon_path = replace(icon_path, ?, ?)
|
||||
WHERE icon_path IS NOT NULL
|
||||
",
|
||||
prev_custom_dir,
|
||||
new_dir,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instance_files
|
||||
SET icon_path = replace(icon_path, ?, ?)
|
||||
WHERE icon_path IS NOT NULL AND icon_path != ''
|
||||
",
|
||||
prev_custom_dir,
|
||||
new_dir,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE instance_launch_overrides
|
||||
SET overrides = jsonb(json_set(
|
||||
overrides,
|
||||
'$.java_path',
|
||||
replace(json_extract(overrides, '$.java_path'), ?, ?)
|
||||
))
|
||||
WHERE json_type(overrides, '$.java_path') = 'text'
|
||||
",
|
||||
prev_custom_dir,
|
||||
new_dir,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
crate::state::instances::adapters::sqlite::config_sync_rows::mark_all_config_dirty(
|
||||
exec,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
settings.custom_dir = Some(new_dir);
|
||||
}
|
||||
|
||||
settings.prev_custom_dir.clone_from(&settings.custom_dir);
|
||||
if settings.custom_dir.is_none() {
|
||||
settings.custom_dir = Some(app_dir.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
settings.update(exec).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod resolve_game_dir_tests {
|
||||
use super::DirectoryInfo;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn dirs() -> DirectoryInfo {
|
||||
DirectoryInfo {
|
||||
settings_dir: PathBuf::from(r"C:\launcher\settings"),
|
||||
config_dir: PathBuf::from(r"C:\launcher"),
|
||||
app_identifier: "test".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_used_when_set_and_absolute() {
|
||||
let dirs = dirs();
|
||||
assert_eq!(
|
||||
dirs.resolve_game_dir("inst", Some(r"D:\Games\.minecraft")),
|
||||
PathBuf::from(r"D:\Games\.minecraft")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_folder_used_without_override() {
|
||||
let dirs = dirs();
|
||||
assert_eq!(
|
||||
dirs.resolve_game_dir("inst", None),
|
||||
dirs.instances_dir().join("inst")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_folder_used_for_empty_override() {
|
||||
let dirs = dirs();
|
||||
assert_eq!(
|
||||
dirs.resolve_game_dir("inst", Some("")),
|
||||
dirs.instances_dir().join("inst")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_folder_used_for_relative_override() {
|
||||
// A relative override must be treated as unset to avoid a cwd-dependent
|
||||
// game working directory.
|
||||
let dirs = dirs();
|
||||
assert_eq!(
|
||||
dirs.resolve_game_dir("inst", Some(r"relative\dir")),
|
||||
dirs.instances_dir().join("inst")
|
||||
);
|
||||
}
|
||||
}
|
||||
238
packages/app-lib/src/state/discord.rs
Normal file
238
packages/app-lib/src/state/discord.rs
Normal file
@ -0,0 +1,238 @@
|
||||
use std::sync::{
|
||||
Arc, Mutex, TryLockError, atomic::AtomicBool, atomic::Ordering,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
use discord_rich_presence::{
|
||||
DiscordIpc, DiscordIpcClient,
|
||||
activity::{Activity, Assets},
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::State;
|
||||
|
||||
pub struct DiscordGuard {
|
||||
client: Arc<Mutex<DiscordIpcClient>>,
|
||||
connected: Arc<AtomicBool>,
|
||||
launcher_activity: Arc<RwLock<String>>,
|
||||
}
|
||||
|
||||
const DISCORD_IPC_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
async fn await_ipc_task<T>(
|
||||
operation: &'static str,
|
||||
timeout: Duration,
|
||||
task: JoinHandle<T>,
|
||||
) -> Option<T> {
|
||||
match tokio::time::timeout(timeout, task).await {
|
||||
Ok(Ok(result)) => Some(result),
|
||||
Ok(Err(error)) => {
|
||||
tracing::warn!(%error, operation, "Discord IPC worker failed");
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(operation, "Discord IPC operation timed out");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DiscordGuard {
|
||||
/// Initialize discord IPC client, and attempt to connect to it
|
||||
/// If it fails, it will still return a DiscordGuard, but the client will be unconnected
|
||||
pub fn init() -> crate::Result<DiscordGuard> {
|
||||
let dipc = DiscordIpcClient::new("1533353147349864458");
|
||||
|
||||
Ok(DiscordGuard {
|
||||
client: Arc::new(Mutex::new(dipc)),
|
||||
connected: Arc::new(AtomicBool::new(false)),
|
||||
launcher_activity: Arc::new(RwLock::new("Idling...".to_string())),
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_ipc<F>(
|
||||
&self,
|
||||
operation: &'static str,
|
||||
connect_if_needed: bool,
|
||||
action: F,
|
||||
) where
|
||||
F: FnOnce(&mut DiscordIpcClient) -> crate::Result<()> + Send + 'static,
|
||||
{
|
||||
let client = self.client.clone();
|
||||
let connected = self.connected.clone();
|
||||
let task = tokio::task::spawn_blocking(move || {
|
||||
let mut client = match client.try_lock() {
|
||||
Ok(client) => client,
|
||||
Err(TryLockError::WouldBlock) => {
|
||||
tracing::warn!(
|
||||
operation,
|
||||
"Discord IPC client is busy; skipping activity update"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(TryLockError::Poisoned(error)) => {
|
||||
tracing::warn!(
|
||||
operation,
|
||||
"Discord IPC client lock was poisoned; recovering"
|
||||
);
|
||||
error.into_inner()
|
||||
}
|
||||
};
|
||||
|
||||
if !connected.load(Ordering::Relaxed) {
|
||||
if !connect_if_needed {
|
||||
return;
|
||||
}
|
||||
if client.connect().is_err() {
|
||||
return;
|
||||
}
|
||||
connected.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
if let Err(error) = action(&mut client) {
|
||||
connected.store(false, Ordering::Relaxed);
|
||||
tracing::warn!(%error, operation, "Discord IPC operation failed");
|
||||
}
|
||||
});
|
||||
|
||||
let _ = await_ipc_task(operation, DISCORD_IPC_TIMEOUT, task).await;
|
||||
}
|
||||
|
||||
/// Set the activity to the given message
|
||||
/// First checks if discord is disabled, and if so, clear the activity instead
|
||||
pub async fn set_activity(
|
||||
&self,
|
||||
msg: &str,
|
||||
reconnect_if_fail: bool,
|
||||
) -> crate::Result<()> {
|
||||
// Check if discord is disabled, and if so, clear the activity instead
|
||||
let state = State::get().await?;
|
||||
let settings = crate::state::Settings::get(&state.pool).await?;
|
||||
if !settings.discord_rpc {
|
||||
Ok(self.clear_activity(true).await?)
|
||||
} else {
|
||||
Ok(self.force_set_activity(msg, reconnect_if_fail).await?)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_launcher_activity(
|
||||
&self,
|
||||
msg: &str,
|
||||
reconnect_if_fail: bool,
|
||||
) -> crate::Result<()> {
|
||||
*self.launcher_activity.write().await = msg.to_string();
|
||||
|
||||
let state = State::get().await?;
|
||||
if state.process_manager.get_all().is_empty() {
|
||||
self.set_activity(msg, reconnect_if_fail).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets the activity to the given message, regardless of if discord is disabled or offline
|
||||
/// Should not be used except for in the above method, or if it is already known that discord is enabled (specifically for state initialization) and we are connected to the internet
|
||||
pub async fn force_set_activity(
|
||||
&self,
|
||||
msg: &str,
|
||||
reconnect_if_fail: bool,
|
||||
) -> crate::Result<()> {
|
||||
let msg = msg.to_string();
|
||||
self.run_ipc("set activity", true, move |client| {
|
||||
let activity = Activity::new().state(&msg).assets(
|
||||
Assets::new()
|
||||
.large_image("modrinth_simple")
|
||||
.large_text("Modrinth Logo"),
|
||||
);
|
||||
let result = client.set_activity(activity.clone());
|
||||
|
||||
if reconnect_if_fail && result.is_err() {
|
||||
client.reconnect()?;
|
||||
client.set_activity(activity)?;
|
||||
} else {
|
||||
result?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the activity entirely ('disabling' the RPC until the next set_activity)
|
||||
pub async fn clear_activity(
|
||||
&self,
|
||||
reconnect_if_fail: bool,
|
||||
) -> crate::Result<()> {
|
||||
self.run_ipc("clear activity", false, move |client| {
|
||||
let result = client.clear_activity();
|
||||
|
||||
if reconnect_if_fail && result.is_err() {
|
||||
client.reconnect()?;
|
||||
client.clear_activity()?;
|
||||
} else {
|
||||
result?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the activity, but if there is a running profile, set the activity to that instead
|
||||
pub async fn clear_to_default(
|
||||
&self,
|
||||
reconnect_if_fail: bool,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
|
||||
let settings = crate::state::Settings::get(&state.pool).await?;
|
||||
if !settings.discord_rpc {
|
||||
println!("Discord is disabled, clearing activity");
|
||||
return self.clear_activity(true).await;
|
||||
}
|
||||
|
||||
let running_instances = state.process_manager.get_all();
|
||||
if let Some(existing_child) = running_instances.first() {
|
||||
self.set_activity(
|
||||
&format!("Playing {}", existing_child.instance_name),
|
||||
reconnect_if_fail,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
let launcher_activity = self.launcher_activity.read().await.clone();
|
||||
self.set_activity(&launcher_activity, reconnect_if_fail)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::await_ipc_task;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn ipc_task_returns_completed_result() {
|
||||
let task = tokio::task::spawn_blocking(|| 42);
|
||||
|
||||
assert_eq!(
|
||||
await_ipc_task("test", Duration::from_secs(1), task).await,
|
||||
Some(42)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ipc_task_stops_waiting_after_timeout() {
|
||||
let task = tokio::task::spawn_blocking(|| {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
42
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
await_ipc_task("test", Duration::from_millis(10), task).await,
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
165
packages/app-lib/src/state/discovered_javas.rs
Normal file
165
packages/app-lib/src/state/discovered_javas.rs
Normal file
@ -0,0 +1,165 @@
|
||||
use std::path::Path;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use sqlx::Row;
|
||||
|
||||
use super::JavaVersion;
|
||||
|
||||
/// A Java installation found by a system scan, cached together with the
|
||||
/// file signature of its executable so staleness can be detected cheaply.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredJava {
|
||||
pub java: JavaVersion,
|
||||
pub file_size: i64,
|
||||
pub file_mtime_ms: i64,
|
||||
}
|
||||
|
||||
/// Returns (size, mtime in milliseconds) of the file at `path`, or None
|
||||
/// if it does not exist or cannot be read.
|
||||
pub fn java_file_signature(path: &Path) -> Option<(i64, i64)> {
|
||||
let metadata = std::fs::metadata(path).ok()?;
|
||||
if !metadata.is_file() {
|
||||
return None;
|
||||
}
|
||||
let mtime_ms = metadata
|
||||
.modified()
|
||||
.ok()?
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()?
|
||||
.as_millis() as i64;
|
||||
Some((metadata.len() as i64, mtime_ms))
|
||||
}
|
||||
|
||||
impl DiscoveredJava {
|
||||
/// Builds a cache entry for a verified Java installation, stamping the
|
||||
/// current signature of its executable. Returns None if the executable
|
||||
/// can no longer be read.
|
||||
pub fn from_java(java: JavaVersion) -> Option<Self> {
|
||||
let (file_size, file_mtime_ms) =
|
||||
java_file_signature(Path::new(&java.path))?;
|
||||
Some(Self {
|
||||
java,
|
||||
file_size,
|
||||
file_mtime_ms,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_all(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Vec<Self>> {
|
||||
let rows = sqlx::query(
|
||||
"
|
||||
SELECT path, major_version, full_version, architecture,
|
||||
file_size, file_mtime_ms, distribution
|
||||
FROM discovered_javas
|
||||
ORDER BY major_version, path
|
||||
",
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| Self {
|
||||
java: JavaVersion {
|
||||
parsed_version: row.get::<i64, _>("major_version") as u32,
|
||||
version: row.get("full_version"),
|
||||
architecture: row.get("architecture"),
|
||||
path: row.get("path"),
|
||||
distribution: row.get("distribution"),
|
||||
},
|
||||
file_size: row.get("file_size"),
|
||||
file_mtime_ms: row.get("file_mtime_ms"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
&self,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO discovered_javas (
|
||||
path, major_version, full_version, architecture,
|
||||
file_size, file_mtime_ms, distribution
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (path) DO UPDATE SET
|
||||
major_version = $2,
|
||||
full_version = $3,
|
||||
architecture = $4,
|
||||
file_size = $5,
|
||||
file_mtime_ms = $6,
|
||||
distribution = $7
|
||||
",
|
||||
)
|
||||
.bind(&self.java.path)
|
||||
.bind(self.java.parsed_version as i64)
|
||||
.bind(&self.java.version)
|
||||
.bind(&self.java.architecture)
|
||||
.bind(self.file_size)
|
||||
.bind(self.file_mtime_ms)
|
||||
.bind(&self.java.distribution)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
path: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
sqlx::query("DELETE FROM discovered_javas WHERE path = $1")
|
||||
.bind(path)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces the entire cache with the results of a fresh scan.
|
||||
pub async fn replace_all(
|
||||
pool: &sqlx::SqlitePool,
|
||||
entries: &[Self],
|
||||
) -> crate::Result<()> {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
sqlx::query("DELETE FROM discovered_javas")
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
for entry in entries {
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO discovered_javas (
|
||||
path, major_version, full_version, architecture,
|
||||
file_size, file_mtime_ms, distribution
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (path) DO UPDATE SET
|
||||
major_version = $2,
|
||||
full_version = $3,
|
||||
architecture = $4,
|
||||
file_size = $5,
|
||||
file_mtime_ms = $6,
|
||||
distribution = $7
|
||||
",
|
||||
)
|
||||
.bind(&entry.java.path)
|
||||
.bind(entry.java.parsed_version as i64)
|
||||
.bind(&entry.java.version)
|
||||
.bind(&entry.java.architecture)
|
||||
.bind(entry.file_size)
|
||||
.bind(entry.file_mtime_ms)
|
||||
.bind(&entry.java.distribution)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
480
packages/app-lib/src/state/friends.rs
Normal file
480
packages/app-lib/src/state/friends.rs
Normal file
@ -0,0 +1,480 @@
|
||||
use crate::ErrorKind;
|
||||
use crate::data::ModrinthCredentials;
|
||||
use crate::event::FriendPayload;
|
||||
use crate::event::emit::{emit_friend, emit_notification};
|
||||
use crate::state::tunnel::InternalTunnelSocket;
|
||||
use crate::state::{ProcessManager, TunnelSocket};
|
||||
use crate::util::fetch::{FetchSemaphore, fetch_advanced, fetch_json};
|
||||
use ariadne::ids::UserId;
|
||||
use ariadne::networking::message::{
|
||||
ClientToServerMessage, ServerToClientMessage,
|
||||
};
|
||||
use ariadne::users::UserStatus;
|
||||
use async_tungstenite::WebSocketSender;
|
||||
use async_tungstenite::tokio::{ConnectStream, connect_async};
|
||||
use async_tungstenite::tungstenite::Message;
|
||||
use async_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use bytes::Bytes;
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use either::Either;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use reqwest::Method;
|
||||
use reqwest::header::HeaderValue;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::net::SocketAddr;
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::net::tcp::OwnedReadHalf;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(super) type WriteSocket =
|
||||
Arc<RwLock<Option<WebSocketSender<ConnectStream>>>>;
|
||||
pub(super) type TunnelSockets = Arc<DashMap<Uuid, Arc<InternalTunnelSocket>>>;
|
||||
|
||||
pub struct FriendsSocket {
|
||||
write: WriteSocket,
|
||||
user_statuses: Arc<DashMap<UserId, UserStatus>>,
|
||||
tunnel_sockets: TunnelSockets,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct UserFriend {
|
||||
pub id: String,
|
||||
pub friend_id: String,
|
||||
pub accepted: bool,
|
||||
pub created: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Default for FriendsSocket {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl FriendsSocket {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
write: Arc::new(RwLock::new(None)),
|
||||
user_statuses: Arc::new(DashMap::new()),
|
||||
tunnel_sockets: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn connect(
|
||||
&self,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
semaphore: &FetchSemaphore,
|
||||
process_manager: &ProcessManager,
|
||||
) -> crate::Result<()> {
|
||||
let credentials =
|
||||
ModrinthCredentials::get_and_refresh(exec, semaphore).await?;
|
||||
|
||||
if let Some(credentials) = credentials {
|
||||
let mut request = format!(
|
||||
"{}_internal/launcher_socket?code={}",
|
||||
env!("MODRINTH_SOCKET_URL"),
|
||||
credentials.session
|
||||
)
|
||||
.into_client_request()?;
|
||||
|
||||
request.headers_mut().insert(
|
||||
"User-Agent",
|
||||
HeaderValue::from_str(&crate::launcher_user_agent()).unwrap(),
|
||||
);
|
||||
|
||||
let res = connect_async(request).await;
|
||||
|
||||
match res {
|
||||
Ok((socket, _)) => {
|
||||
tracing::info!("Connected to friends socket");
|
||||
let (write, read) = socket.split();
|
||||
|
||||
{
|
||||
let mut write_lock = self.write.write().await;
|
||||
*write_lock = Some(write);
|
||||
}
|
||||
|
||||
if let Some(process) = process_manager.get_all().first() {
|
||||
let _ = self
|
||||
.update_status(Some(process.instance_name.clone()))
|
||||
.await;
|
||||
}
|
||||
|
||||
let write_handle = self.write.clone();
|
||||
let statuses = self.user_statuses.clone();
|
||||
let sockets = self.tunnel_sockets.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut read_stream = read;
|
||||
while let Some(msg_result) = read_stream.next().await {
|
||||
match msg_result {
|
||||
Ok(msg) => {
|
||||
let server_message = match msg {
|
||||
Message::Text(text) => {
|
||||
match ServerToClientMessage::deserialize(
|
||||
Either::Left(&text),
|
||||
) {
|
||||
Ok(message) => Some(message),
|
||||
Err(_) => {
|
||||
if let Ok(notification) =
|
||||
serde_json::from_str::<Value>(&text)
|
||||
{
|
||||
let _ = Self::handle_notification(notification).await;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Binary(bytes) => {
|
||||
match ServerToClientMessage::deserialize(
|
||||
Either::Right(&bytes),
|
||||
) {
|
||||
Ok(message) => Some(message),
|
||||
Err(_) => {
|
||||
if let Ok(notification) =
|
||||
serde_json::from_slice::<Value>(&bytes)
|
||||
{
|
||||
let _ = Self::handle_notification(notification).await;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Ping(bytes) => {
|
||||
if let Some(write) = write_handle
|
||||
.write()
|
||||
.await
|
||||
.as_mut()
|
||||
{
|
||||
let _ = write
|
||||
.send(Message::Pong(bytes))
|
||||
.await;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
Message::Pong(_)
|
||||
| Message::Frame(_) => continue,
|
||||
Message::Close(_) => break,
|
||||
};
|
||||
|
||||
if let Some(server_message) = server_message
|
||||
{
|
||||
match server_message {
|
||||
ServerToClientMessage::StatusUpdate { status } => {
|
||||
statuses.insert(status.user_id, status.clone());
|
||||
let _ = emit_friend(FriendPayload::StatusUpdate { user_status: status }).await;
|
||||
},
|
||||
ServerToClientMessage::UserOffline { id } => {
|
||||
statuses.remove(&id);
|
||||
let _ = emit_friend(FriendPayload::UserOffline { id }).await;
|
||||
}
|
||||
ServerToClientMessage::FriendStatuses { statuses: new_statuses } => {
|
||||
statuses.clear();
|
||||
new_statuses.into_iter().for_each(|status| {
|
||||
statuses.insert(status.user_id, status);
|
||||
});
|
||||
let _ = emit_friend(FriendPayload::StatusSync).await;
|
||||
}
|
||||
ServerToClientMessage::FriendRequest { from } => {
|
||||
let _ = emit_friend(FriendPayload::FriendRequest { from }).await;
|
||||
}
|
||||
ServerToClientMessage::FriendRequestRejected { .. } => {}, // TODO
|
||||
|
||||
ServerToClientMessage::FriendSocketListening { .. } => {}, // TODO
|
||||
ServerToClientMessage::FriendSocketStoppedListening { .. } => {}, // TODO
|
||||
|
||||
ServerToClientMessage::SocketConnected { to_socket, new_socket } => {
|
||||
if let Some(connected_to) = sockets.get(&to_socket)
|
||||
&& let InternalTunnelSocket::Listening(local_addr) = *connected_to.value().clone()
|
||||
&& let Ok(new_stream) = TcpStream::connect(local_addr).await {
|
||||
let (read, write) = new_stream.into_split();
|
||||
sockets.insert(new_socket, Arc::new(InternalTunnelSocket::Connected(Mutex::new(write))));
|
||||
Self::socket_read_loop(write_handle.clone(), read, new_socket);
|
||||
continue;
|
||||
}
|
||||
let _ = Self::send_message(&write_handle, ClientToServerMessage::SocketClose { socket: new_socket }).await;
|
||||
},
|
||||
ServerToClientMessage::SocketClosed { socket } => {
|
||||
sockets.remove_if(&socket, |_, x| matches!(*x.clone(), InternalTunnelSocket::Connected(_)));
|
||||
},
|
||||
ServerToClientMessage::SocketData { socket, data } => {
|
||||
if let Some(mut socket) = sockets.get_mut(&socket)
|
||||
&& let InternalTunnelSocket::Connected(ref stream) = *socket.value_mut().clone() {
|
||||
let _ = stream.lock().await.write_all(&data).await;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Error handling message from websocket server: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut w = write_handle.write().await;
|
||||
*w = None;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Error connecting to friends socket: {e:?}"
|
||||
);
|
||||
|
||||
return Err(crate::Error::from(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_notification(notification: Value) -> crate::Result<()> {
|
||||
if notification
|
||||
.get("body")
|
||||
.and_then(|body| body.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some()
|
||||
{
|
||||
emit_notification(notification).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn socket_loop() -> crate::Result<()> {
|
||||
let state = crate::State::get().await?;
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
let mut last_connection = Utc::now();
|
||||
let mut last_ping = Utc::now();
|
||||
|
||||
loop {
|
||||
let connected = state.friends_socket.is_connected().await;
|
||||
|
||||
if !connected
|
||||
&& Utc::now().signed_duration_since(last_connection)
|
||||
> chrono::Duration::seconds(30)
|
||||
{
|
||||
last_connection = Utc::now();
|
||||
last_ping = Utc::now();
|
||||
let _ = state
|
||||
.friends_socket
|
||||
.connect(
|
||||
&state.pool,
|
||||
&state.api_semaphore,
|
||||
&state.process_manager,
|
||||
)
|
||||
.await;
|
||||
} else if connected
|
||||
&& Utc::now().signed_duration_since(last_ping)
|
||||
> chrono::Duration::seconds(10)
|
||||
{
|
||||
last_ping = Utc::now();
|
||||
let mut write = state.friends_socket.write.write().await;
|
||||
if let Some(write) = write.as_mut() {
|
||||
let _ = write.send(Message::Ping(Bytes::new())).await;
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn disconnect(&self) -> crate::Result<()> {
|
||||
let mut write_lock = self.write.write().await;
|
||||
if let Some(ref mut write_half) = *write_lock {
|
||||
SinkExt::close(write_half).await?;
|
||||
*write_lock = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn update_status(
|
||||
&self,
|
||||
instance_name: Option<String>,
|
||||
) -> crate::Result<()> {
|
||||
Self::send_message(
|
||||
&self.write,
|
||||
ClientToServerMessage::StatusUpdate {
|
||||
profile_name: instance_name,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn friends(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<Vec<UserFriend>> {
|
||||
fetch_json(
|
||||
Method::GET,
|
||||
concat!(env!("MODRINTH_API_URL_V3"), "friends"),
|
||||
None,
|
||||
None,
|
||||
Some("/v3/friends"),
|
||||
semaphore,
|
||||
exec,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub fn friend_statuses(&self) -> Vec<UserStatus> {
|
||||
self.user_statuses
|
||||
.iter()
|
||||
.map(|x| x.value().clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(exec, semaphore))]
|
||||
pub async fn add_friend(
|
||||
user_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<()> {
|
||||
let result = fetch_advanced(
|
||||
Method::POST,
|
||||
&format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("/v3/friend/:user_id"),
|
||||
semaphore,
|
||||
exec,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(ref e) = result
|
||||
&& let ErrorKind::LabrinthError(e) = &*e.raw
|
||||
&& e.error == "not_found"
|
||||
{
|
||||
return Err(ErrorKind::OtherError(format!(
|
||||
"No user found with username \"{user_id}\""
|
||||
))
|
||||
.into());
|
||||
}
|
||||
result?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(exec, semaphore))]
|
||||
pub async fn remove_friend(
|
||||
user_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<()> {
|
||||
fetch_advanced(
|
||||
Method::DELETE,
|
||||
&format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("/v3/friend/:user_id"),
|
||||
semaphore,
|
||||
exec,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn open_port(&self, port: u16) -> crate::Result<TunnelSocket> {
|
||||
let socket_id = Uuid::new_v4();
|
||||
let socket = self.tunnel_sockets.entry(socket_id).insert(Arc::new(
|
||||
InternalTunnelSocket::Listening(SocketAddr::new(
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
port,
|
||||
)),
|
||||
));
|
||||
Self::send_message(
|
||||
&self.write,
|
||||
ClientToServerMessage::SocketListen { socket: socket_id },
|
||||
)
|
||||
.await?;
|
||||
self.create_tunnel_socket(socket_id, socket)
|
||||
}
|
||||
|
||||
pub async fn is_connected(&self) -> bool {
|
||||
self.write.read().await.is_some()
|
||||
}
|
||||
|
||||
fn create_tunnel_socket(
|
||||
&self,
|
||||
socket_id: Uuid,
|
||||
socket: impl Deref<Target = Arc<InternalTunnelSocket>>,
|
||||
) -> crate::Result<TunnelSocket> {
|
||||
Ok(TunnelSocket {
|
||||
socket_id,
|
||||
write: self.write.clone(),
|
||||
sockets: self.tunnel_sockets.clone(),
|
||||
internal: socket.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn socket_read_loop(
|
||||
write: WriteSocket,
|
||||
mut read_half: OwnedReadHalf,
|
||||
socket_id: Uuid,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut read_buffer = [0u8; 8192];
|
||||
loop {
|
||||
match read_half.read(&mut read_buffer).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
let _ = Self::send_message(
|
||||
&write,
|
||||
ClientToServerMessage::SocketSend {
|
||||
socket: socket_id,
|
||||
data: read_buffer[..n].to_vec(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(write))]
|
||||
pub(super) async fn send_message(
|
||||
write: &WriteSocket,
|
||||
message: ClientToServerMessage,
|
||||
) -> crate::Result<()> {
|
||||
let serialized = match message.serialize()? {
|
||||
Either::Left(text) => Message::text(text),
|
||||
Either::Right(bytes) => Message::binary(bytes),
|
||||
};
|
||||
|
||||
let mut write_lock = write.write().await;
|
||||
if let Some(ref mut write_half) = *write_lock {
|
||||
write_half.send(serialized).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
265
packages/app-lib/src/state/installer_settings.rs
Normal file
265
packages/app-lib/src/state/installer_settings.rs
Normal file
@ -0,0 +1,265 @@
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows {
|
||||
use super::super::{DirectoryInfo, Settings};
|
||||
use sqlx::SqlitePool;
|
||||
use std::path::{Path, PathBuf};
|
||||
use winreg::RegKey;
|
||||
use winreg::enums::{HKEY_CURRENT_USER, KEY_READ, KEY_SET_VALUE};
|
||||
|
||||
const INSTALLER_REGISTRY_KEY: &str = "Software\\ghs\\Axolotl Launcher";
|
||||
const PENDING_RESOURCE_DIRECTORY_VALUE: &str = "PendingResourceDirectory";
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
enum PendingDirectoryDecision {
|
||||
Apply {
|
||||
custom_dir: String,
|
||||
prev_custom_dir: String,
|
||||
},
|
||||
Clear,
|
||||
Ignore,
|
||||
}
|
||||
|
||||
fn normalize_directory(path: &str) -> Option<String> {
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let path = PathBuf::from(path);
|
||||
if !path.is_absolute() || path.parent().is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut normalized = path.to_string_lossy().replace('/', "\\");
|
||||
while normalized.len() > 3 && normalized.ends_with('\\') {
|
||||
normalized.pop();
|
||||
}
|
||||
|
||||
Some(normalized)
|
||||
}
|
||||
|
||||
fn decide_pending_directory(
|
||||
pending_directory: Option<&str>,
|
||||
default_directory: &Path,
|
||||
settings_initialized: bool,
|
||||
portable: bool,
|
||||
) -> PendingDirectoryDecision {
|
||||
if portable {
|
||||
return PendingDirectoryDecision::Ignore;
|
||||
}
|
||||
|
||||
let Some(pending_directory) = pending_directory else {
|
||||
return PendingDirectoryDecision::Ignore;
|
||||
};
|
||||
|
||||
if settings_initialized {
|
||||
return PendingDirectoryDecision::Clear;
|
||||
}
|
||||
|
||||
let Some(custom_dir) = normalize_directory(pending_directory) else {
|
||||
return PendingDirectoryDecision::Clear;
|
||||
};
|
||||
let Some(prev_custom_dir) =
|
||||
normalize_directory(&default_directory.to_string_lossy())
|
||||
else {
|
||||
return PendingDirectoryDecision::Clear;
|
||||
};
|
||||
|
||||
if custom_dir.eq_ignore_ascii_case(&prev_custom_dir) {
|
||||
return PendingDirectoryDecision::Clear;
|
||||
}
|
||||
|
||||
PendingDirectoryDecision::Apply {
|
||||
custom_dir,
|
||||
prev_custom_dir,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_installer_registry_key() -> std::io::Result<RegKey> {
|
||||
RegKey::predef(HKEY_CURRENT_USER).open_subkey_with_flags(
|
||||
INSTALLER_REGISTRY_KEY,
|
||||
KEY_READ | KEY_SET_VALUE,
|
||||
)
|
||||
}
|
||||
|
||||
fn clear_pending_directory(key: &RegKey) -> crate::Result<()> {
|
||||
match key.delete_value(PENDING_RESOURCE_DIRECTORY_VALUE) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply_pending_installer_directory(
|
||||
settings: &mut Settings,
|
||||
pool: &SqlitePool,
|
||||
app_identifier: &str,
|
||||
) -> crate::Result<()> {
|
||||
if std::env::var_os("THESEUS_CONFIG_DIR").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let key = match open_installer_registry_key() {
|
||||
Ok(key) => key,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let pending_directory: Option<String> =
|
||||
match key.get_value(PENDING_RESOURCE_DIRECTORY_VALUE) {
|
||||
Ok(value) => Some(value),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let default_directory = DirectoryInfo::initial_settings_dir_path(
|
||||
app_identifier,
|
||||
)
|
||||
.ok_or(crate::ErrorKind::FSError(
|
||||
"Could not find valid config dir".to_string(),
|
||||
))?;
|
||||
let settings_initialized =
|
||||
settings.custom_dir.is_some() || settings.prev_custom_dir.is_some();
|
||||
|
||||
match decide_pending_directory(
|
||||
pending_directory.as_deref(),
|
||||
&default_directory,
|
||||
settings_initialized,
|
||||
false,
|
||||
) {
|
||||
PendingDirectoryDecision::Apply {
|
||||
custom_dir,
|
||||
prev_custom_dir,
|
||||
} => {
|
||||
tracing::info!(
|
||||
"Applying the application directory selected by the installer"
|
||||
);
|
||||
settings.custom_dir = Some(custom_dir);
|
||||
settings.prev_custom_dir = Some(prev_custom_dir);
|
||||
settings.update(pool).await?;
|
||||
clear_pending_directory(&key)?;
|
||||
}
|
||||
PendingDirectoryDecision::Clear => {
|
||||
clear_pending_directory(&key)?;
|
||||
}
|
||||
PendingDirectoryDecision::Ignore => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn default_directory() -> PathBuf {
|
||||
PathBuf::from(r"C:\Users\Test\AppData\Roaming\red.ghs.axolotl")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_a_custom_installer_directory() {
|
||||
assert_eq!(
|
||||
decide_pending_directory(
|
||||
Some(r"D:\Minecraft\Axolotl"),
|
||||
&default_directory(),
|
||||
false,
|
||||
false,
|
||||
),
|
||||
PendingDirectoryDecision::Apply {
|
||||
custom_dir: r"D:\Minecraft\Axolotl".to_string(),
|
||||
prev_custom_dir: default_directory()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clears_a_default_installer_directory() {
|
||||
assert_eq!(
|
||||
decide_pending_directory(
|
||||
Some(r"C:\Users\Test\AppData\Roaming\red.ghs.axolotl\"),
|
||||
&default_directory(),
|
||||
false,
|
||||
false,
|
||||
),
|
||||
PendingDirectoryDecision::Clear
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_override_existing_settings() {
|
||||
assert_eq!(
|
||||
decide_pending_directory(
|
||||
Some(r"D:\Minecraft\Axolotl"),
|
||||
&default_directory(),
|
||||
true,
|
||||
false,
|
||||
),
|
||||
PendingDirectoryDecision::Clear
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clears_invalid_relative_and_root_directories() {
|
||||
for path in ["relative", r"C:\", ""] {
|
||||
assert_eq!(
|
||||
decide_pending_directory(
|
||||
Some(path),
|
||||
&default_directory(),
|
||||
false,
|
||||
false,
|
||||
),
|
||||
PendingDirectoryDecision::Clear
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_mode_leaves_the_pending_value_for_the_installed_app() {
|
||||
assert_eq!(
|
||||
decide_pending_directory(
|
||||
Some(r"D:\Minecraft\Axolotl"),
|
||||
&default_directory(),
|
||||
false,
|
||||
true,
|
||||
),
|
||||
PendingDirectoryDecision::Ignore
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_state_can_restore_the_default_directory() {
|
||||
let PendingDirectoryDecision::Apply {
|
||||
custom_dir: _,
|
||||
prev_custom_dir,
|
||||
} = decide_pending_directory(
|
||||
Some(r"D:\Minecraft\Axolotl"),
|
||||
&default_directory(),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
else {
|
||||
panic!("expected installer directory to be applied");
|
||||
};
|
||||
|
||||
assert_eq!(prev_custom_dir, default_directory().to_string_lossy());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use windows::apply_pending_installer_directory;
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub async fn apply_pending_installer_directory(
|
||||
_settings: &mut super::Settings,
|
||||
_pool: &sqlx::SqlitePool,
|
||||
_app_identifier: &str,
|
||||
) -> crate::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
295
packages/app-lib/src/state/instance_types.rs
Normal file
295
packages/app-lib/src/state/instance_types.rs
Normal file
@ -0,0 +1,295 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstanceInstallStage {
|
||||
Installed,
|
||||
MinecraftInstalling,
|
||||
PackInstalled,
|
||||
PackInstalling,
|
||||
NotInstalled,
|
||||
}
|
||||
|
||||
impl InstanceInstallStage {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match *self {
|
||||
Self::Installed => "installed",
|
||||
Self::MinecraftInstalling => "minecraft_installing",
|
||||
Self::PackInstalled => "pack_installed",
|
||||
Self::PackInstalling => "pack_installing",
|
||||
Self::NotInstalled => "not_installed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(val: &str) -> Self {
|
||||
match val {
|
||||
"installed" => Self::Installed,
|
||||
"minecraft_installing" => Self::MinecraftInstalling,
|
||||
"installing" => Self::MinecraftInstalling,
|
||||
"pack_installed" => Self::PackInstalled,
|
||||
"pack_installing" => Self::PackInstalling,
|
||||
"not_installed" => Self::NotInstalled,
|
||||
_ => Self::NotInstalled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LauncherFeatureVersion {
|
||||
None,
|
||||
MigratedServerLastPlayTime,
|
||||
MigratedLaunchHooks,
|
||||
}
|
||||
|
||||
impl LauncherFeatureVersion {
|
||||
pub const MOST_RECENT: Self = Self::MigratedLaunchHooks;
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match *self {
|
||||
Self::None => "none",
|
||||
Self::MigratedServerLastPlayTime => {
|
||||
"migrated_server_last_play_time"
|
||||
}
|
||||
Self::MigratedLaunchHooks => "migrated_launch_hooks",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(val: &str) -> Self {
|
||||
match val {
|
||||
"none" => Self::None,
|
||||
"migrated_server_last_play_time" => {
|
||||
Self::MigratedServerLastPlayTime
|
||||
}
|
||||
"migrated_launch_hooks" => Self::MigratedLaunchHooks,
|
||||
_ => Self::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Copy, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ModLoader {
|
||||
Vanilla,
|
||||
Forge,
|
||||
Fabric,
|
||||
Quilt,
|
||||
#[serde(rename = "neoforge", alias = "neo_forge", alias = "neo")]
|
||||
NeoForge,
|
||||
#[serde(rename = "optifine", alias = "opti_fine")]
|
||||
OptiFine,
|
||||
Cleanroom,
|
||||
LiteLoader,
|
||||
LegacyFabric,
|
||||
Babric,
|
||||
}
|
||||
|
||||
impl ModLoader {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match *self {
|
||||
Self::Vanilla => "vanilla",
|
||||
Self::Forge => "forge",
|
||||
Self::Fabric => "fabric",
|
||||
Self::Quilt => "quilt",
|
||||
Self::NeoForge => "neoforge",
|
||||
Self::OptiFine => "optifine",
|
||||
Self::Cleanroom => "cleanroom",
|
||||
Self::LiteLoader => "lite_loader",
|
||||
Self::LegacyFabric => "legacy_fabric",
|
||||
Self::Babric => "babric",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_meta_str(&self) -> &'static str {
|
||||
match *self {
|
||||
Self::Vanilla => "vanilla",
|
||||
Self::Forge => "forge",
|
||||
Self::Fabric => "fabric",
|
||||
Self::Quilt => "quilt",
|
||||
Self::NeoForge => "neo",
|
||||
// OptiFine has no Daedalus metadata; versions resolve through
|
||||
// launcher::optifine instead of the meta server.
|
||||
Self::OptiFine => "optifine",
|
||||
Self::Cleanroom => "cleanroom",
|
||||
Self::LiteLoader => "lite_loader",
|
||||
Self::LegacyFabric => "legacy_fabric",
|
||||
Self::Babric => "babric",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_from_string(val: &str) -> crate::Result<Self> {
|
||||
match val {
|
||||
"vanilla" => Ok(Self::Vanilla),
|
||||
"forge" => Ok(Self::Forge),
|
||||
"fabric" => Ok(Self::Fabric),
|
||||
"quilt" => Ok(Self::Quilt),
|
||||
"neoforge" | "neo_forge" | "neo" => Ok(Self::NeoForge),
|
||||
"optifine" | "opti_fine" => Ok(Self::OptiFine),
|
||||
"cleanroom" => Ok(Self::Cleanroom),
|
||||
"lite_loader" | "liteloader" => Ok(Self::LiteLoader),
|
||||
"legacy_fabric" | "legacyfabric" => Ok(Self::LegacyFabric),
|
||||
"babric" => Ok(Self::Babric),
|
||||
other => Err(crate::ErrorKind::InputError(format!(
|
||||
"Unsupported loader {other}"
|
||||
))
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ContentFile {
|
||||
pub hash: String,
|
||||
pub file_name: String,
|
||||
pub enabled: bool,
|
||||
pub size: u64,
|
||||
pub modrinth: Option<ModrinthFileMatch>,
|
||||
pub provider_refs: Vec<crate::state::ContentProviderRef>,
|
||||
pub origin_provider: Option<crate::state::ContentProvider>,
|
||||
pub update: Option<crate::state::ContentItemUpdate>,
|
||||
pub project_type: ProjectType,
|
||||
/// JSON-encoded `LocalModMetadata` extracted from the JAR's embedded
|
||||
/// mod metadata file. Populated when Modrinth hash lookup provides
|
||||
/// no match for the SHA1. Used as fallback display data.
|
||||
pub local_mod_data: Option<String>,
|
||||
/// Absolute path of the cached extracted icon; empty string marks a file
|
||||
/// that was checked but has no icon.
|
||||
pub icon_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ModrinthFileMatch {
|
||||
pub project_id: crate::state::ModrinthProjectId,
|
||||
pub version_id: crate::state::ModrinthVersionId,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProjectType {
|
||||
Mod,
|
||||
DataPack,
|
||||
ResourcePack,
|
||||
#[serde(alias = "shader")]
|
||||
ShaderPack,
|
||||
Schematic,
|
||||
WorldSave,
|
||||
}
|
||||
|
||||
impl ProjectType {
|
||||
pub fn get_from_loaders(loaders: Vec<String>) -> Option<Self> {
|
||||
if loaders.iter().any(|x| {
|
||||
[
|
||||
"fabric",
|
||||
"forge",
|
||||
"quilt",
|
||||
"neoforge",
|
||||
"cleanroom",
|
||||
"lite_loader",
|
||||
"legacy_fabric",
|
||||
"babric",
|
||||
]
|
||||
.contains(&&**x)
|
||||
}) {
|
||||
Some(ProjectType::Mod)
|
||||
} else if loaders.iter().any(|x| x == "datapack") {
|
||||
Some(ProjectType::DataPack)
|
||||
} else if loaders.iter().any(|x| ["iris", "optifine"].contains(&&**x)) {
|
||||
Some(ProjectType::ShaderPack)
|
||||
} else if loaders
|
||||
.iter()
|
||||
.any(|x| ["vanilla", "canvas", "minecraft"].contains(&&**x))
|
||||
{
|
||||
Some(ProjectType::ResourcePack)
|
||||
} else if loaders.iter().any(|x| x == "litematica") {
|
||||
Some(ProjectType::Schematic)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_from_parent_folder(path: impl AsRef<Path>) -> Option<Self> {
|
||||
Self::from_folder_name(
|
||||
path.as_ref()
|
||||
.parent()?
|
||||
.file_name()?
|
||||
.to_str()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn from_folder_name(folder_name: &str) -> Option<Self> {
|
||||
match folder_name {
|
||||
"mods" => Some(ProjectType::Mod),
|
||||
"datapacks" => Some(ProjectType::DataPack),
|
||||
"resourcepacks" => Some(ProjectType::ResourcePack),
|
||||
"shaderpacks" => Some(ProjectType::ShaderPack),
|
||||
"schematics" => Some(ProjectType::Schematic),
|
||||
"saves" => Some(ProjectType::WorldSave),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_name(&self) -> &'static str {
|
||||
match self {
|
||||
ProjectType::Mod => "mod",
|
||||
ProjectType::DataPack => "datapack",
|
||||
ProjectType::ResourcePack => "resourcepack",
|
||||
ProjectType::ShaderPack => "shader",
|
||||
ProjectType::Schematic => "schematic",
|
||||
ProjectType::WorldSave => "world_save",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_folder(&self) -> &'static str {
|
||||
match self {
|
||||
ProjectType::Mod => "mods",
|
||||
ProjectType::DataPack => "datapacks",
|
||||
ProjectType::ResourcePack => "resourcepacks",
|
||||
ProjectType::ShaderPack => "shaderpacks",
|
||||
ProjectType::Schematic => "schematics",
|
||||
ProjectType::WorldSave => "saves",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_loaders(&self) -> &'static [&'static str] {
|
||||
match self {
|
||||
ProjectType::Mod => &["fabric", "forge", "quilt", "neoforge"],
|
||||
ProjectType::DataPack => &["datapack"],
|
||||
ProjectType::ResourcePack => &["vanilla", "canvas", "minecraft"],
|
||||
ProjectType::ShaderPack => &["iris", "optifine"],
|
||||
ProjectType::Schematic => &["litematica"],
|
||||
ProjectType::WorldSave => &["vanilla"],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn iterator() -> impl Iterator<Item = ProjectType> {
|
||||
[
|
||||
ProjectType::Mod,
|
||||
ProjectType::DataPack,
|
||||
ProjectType::ResourcePack,
|
||||
ProjectType::ShaderPack,
|
||||
ProjectType::Schematic,
|
||||
ProjectType::WorldSave,
|
||||
]
|
||||
.iter()
|
||||
.copied()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProjectType> for modrinth_content_management::ContentType {
|
||||
fn from(project_type: ProjectType) -> Self {
|
||||
match project_type {
|
||||
ProjectType::Mod => Self::Mod,
|
||||
ProjectType::DataPack => Self::DataPack,
|
||||
ProjectType::ResourcePack => Self::ResourcePack,
|
||||
ProjectType::ShaderPack => Self::Shader,
|
||||
// Schematic and WorldSave are local-only types that never go through
|
||||
// Modrinth API resolution; map to Mod as a reasonable default.
|
||||
ProjectType::Schematic => Self::Mod,
|
||||
ProjectType::WorldSave => Self::Mod,
|
||||
}
|
||||
}
|
||||
}
|
||||
370
packages/app-lib/src/state/instances/adapters/filesystem.rs
Normal file
370
packages/app-lib/src/state/instances/adapters/filesystem.rs
Normal file
@ -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",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
2
packages/app-lib/src/state/instances/adapters/mod.rs
Normal file
2
packages/app-lib/src/state/instances/adapters/mod.rs
Normal file
@ -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)
|
||||
}
|
||||
2830
packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs
Normal file
2830
packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs
Normal file
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
|
||||
)
|
||||
}
|
||||
1834
packages/app-lib/src/state/instances/commands/content_snapshot.rs
Normal file
1834
packages/app-lib/src/state/instances/commands/content_snapshot.rs
Normal file
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(())
|
||||
}
|
||||
}
|
||||
354
packages/app-lib/src/state/instances/commands/create_instance.rs
Normal file
354
packages/app-lib/src/state/instances/commands/create_instance.rs
Normal file
@ -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(
|
||||
['/', '\\', '?', '*', ':', '\'', '\"', '|', '<', '>', '!'],
|
||||
"_",
|
||||
)
|
||||
}
|
||||
380
packages/app-lib/src/state/instances/commands/edit_instance.rs
Normal file
380
packages/app-lib/src/state/instances/commands/edit_instance.rs
Normal file
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
6789
packages/app-lib/src/state/instances/commands/instance_upgrade.rs
Normal file
6789
packages/app-lib/src/state/instances/commands/instance_upgrade.rs
Normal file
File diff suppressed because it is too large
Load Diff
720
packages/app-lib/src/state/instances/commands/launch_context.rs
Normal file
720
packages/app-lib/src/state/instances/commands/launch_context.rs
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
3287
packages/app-lib/src/state/instances/commands/list_content.rs
Normal file
3287
packages/app-lib/src/state/instances/commands/list_content.rs
Normal file
File diff suppressed because it is too large
Load Diff
75
packages/app-lib/src/state/instances/commands/mod.rs
Normal file
75
packages/app-lib/src/state/instances/commands/mod.rs
Normal file
@ -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,
|
||||
}],
|
||||
));
|
||||
}
|
||||
}
|
||||
809
packages/app-lib/src/state/instances/config_sync.rs
Normal file
809
packages/app-lib/src/state/instances/config_sync.rs
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
86
packages/app-lib/src/state/instances/content.rs
Normal file
86
packages/app-lib/src/state/instances/content.rs
Normal file
@ -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>,
|
||||
}
|
||||
32
packages/app-lib/src/state/instances/mod.rs
Normal file
32
packages/app-lib/src/state/instances/mod.rs
Normal file
@ -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,
|
||||
}
|
||||
52
packages/app-lib/src/state/instances/model/content_entry.rs
Normal file
52
packages/app-lib/src/state/instances/model/content_entry.rs
Normal file
@ -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>,
|
||||
}
|
||||
255
packages/app-lib/src/state/instances/model/content_ownership.rs
Normal file
255
packages/app-lib/src/state/instances/model/content_ownership.rs
Normal file
@ -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");
|
||||
}
|
||||
}
|
||||
350
packages/app-lib/src/state/instances/model/content_provider.rs
Normal file
350
packages/app-lib/src/state/instances/model/content_provider.rs
Normal file
@ -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());
|
||||
}
|
||||
}
|
||||
92
packages/app-lib/src/state/instances/model/content_set.rs
Normal file
92
packages/app-lib/src/state/instances/model/content_set.rs
Normal file
@ -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,
|
||||
}
|
||||
50
packages/app-lib/src/state/instances/model/core_component.rs
Normal file
50
packages/app-lib/src/state/instances/model/core_component.rs
Normal file
@ -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,
|
||||
}
|
||||
23
packages/app-lib/src/state/instances/model/file.rs
Normal file
23
packages/app-lib/src/state/instances/model/file.rs
Normal file
@ -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,
|
||||
}
|
||||
90
packages/app-lib/src/state/instances/model/instance.rs
Normal file
90
packages/app-lib/src/state/instances/model/instance.rs
Normal file
@ -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>,
|
||||
}
|
||||
106
packages/app-lib/src/state/instances/model/launch.rs
Normal file
106
packages/app-lib/src/state/instances/model/launch.rs
Normal file
@ -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,
|
||||
}
|
||||
37
packages/app-lib/src/state/instances/model/link.rs
Normal file
37
packages/app-lib/src/state/instances/model/link.rs
Normal file
@ -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,
|
||||
},
|
||||
}
|
||||
302
packages/app-lib/src/state/instances/model/loader_component.rs
Normal file
302
packages/app-lib/src/state/instances/model/loader_component.rs
Normal file
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
packages/app-lib/src/state/instances/model/manifest.rs
Normal file
11
packages/app-lib/src/state/instances/model/manifest.rs
Normal file
@ -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>,
|
||||
}
|
||||
67
packages/app-lib/src/state/instances/model/mod.rs
Normal file
67
packages/app-lib/src/state/instances/model/mod.rs
Normal file
@ -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>,
|
||||
}
|
||||
13
packages/app-lib/src/state/instances/model/update_check.rs
Normal file
13
packages/app-lib/src/state/instances/model/update_check.rs
Normal file
@ -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>,
|
||||
}
|
||||
744
packages/app-lib/src/state/instances/watcher.rs
Normal file
744
packages/app-lib/src/state/instances/watcher.rs
Normal file
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
209
packages/app-lib/src/state/java_globals.rs
Normal file
209
packages/app-lib/src/state/java_globals.rs
Normal file
@ -0,0 +1,209 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Clone)]
|
||||
pub struct JavaVersion {
|
||||
pub parsed_version: u32,
|
||||
pub version: String,
|
||||
pub architecture: String,
|
||||
pub path: String,
|
||||
pub distribution: Option<String>,
|
||||
}
|
||||
|
||||
impl JavaVersion {
|
||||
pub async fn get(
|
||||
major_version: u32,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Option<JavaVersion>> {
|
||||
let row = sqlx::query(
|
||||
"
|
||||
SELECT
|
||||
java_versions.major_version,
|
||||
java_versions.full_version,
|
||||
java_versions.architecture,
|
||||
java_versions.path,
|
||||
java_versions.distribution
|
||||
FROM java_default_versions
|
||||
INNER JOIN java_versions
|
||||
ON java_versions.major_version = java_default_versions.major_version
|
||||
AND java_versions.path = java_default_versions.path
|
||||
WHERE java_default_versions.major_version = $1
|
||||
",
|
||||
)
|
||||
.bind(major_version as i64)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|row| JavaVersion {
|
||||
parsed_version: row.get::<i64, _>("major_version") as u32,
|
||||
version: row.get("full_version"),
|
||||
architecture: row.get("architecture"),
|
||||
path: row.get("path"),
|
||||
distribution: row.get("distribution"),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_all_defaults(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Vec<Self>> {
|
||||
let rows = sqlx::query(
|
||||
"
|
||||
SELECT
|
||||
java_versions.major_version,
|
||||
java_versions.full_version,
|
||||
java_versions.architecture,
|
||||
java_versions.path,
|
||||
java_versions.distribution
|
||||
FROM java_default_versions
|
||||
INNER JOIN java_versions
|
||||
ON java_versions.major_version = java_default_versions.major_version
|
||||
AND java_versions.path = java_default_versions.path
|
||||
ORDER BY java_versions.major_version DESC
|
||||
",
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| JavaVersion {
|
||||
parsed_version: row.get::<i64, _>("major_version") as u32,
|
||||
version: row.get("full_version"),
|
||||
architecture: row.get("architecture"),
|
||||
path: row.get("path"),
|
||||
distribution: row.get("distribution"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_all(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Vec<Self>> {
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT major_version, full_version, architecture, path, distribution as "distribution?: String" FROM java_versions"#
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|x| JavaVersion {
|
||||
parsed_version: x.major_version as u32,
|
||||
version: x.full_version,
|
||||
architecture: x.architecture,
|
||||
path: x.path,
|
||||
distribution: x.distribution,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
&self,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let major_version = self.parsed_version as i32;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO java_versions (major_version, full_version, architecture, path, distribution)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (path) DO UPDATE SET
|
||||
major_version = $1,
|
||||
full_version = $2,
|
||||
architecture = $3,
|
||||
distribution = $5
|
||||
",
|
||||
major_version,
|
||||
self.version,
|
||||
self.architecture,
|
||||
self.path,
|
||||
self.distribution,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_default(
|
||||
major_version: u32,
|
||||
path: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
sqlx::query(
|
||||
"
|
||||
INSERT INTO java_default_versions (major_version, path)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (major_version) DO UPDATE SET path = $2
|
||||
",
|
||||
)
|
||||
.bind(major_version as i64)
|
||||
.bind(path)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_default(
|
||||
major_version: u32,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
sqlx::query(
|
||||
"DELETE FROM java_default_versions WHERE major_version = $1",
|
||||
)
|
||||
.bind(major_version as i64)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_default_for_path(
|
||||
path: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
sqlx::query("DELETE FROM java_default_versions WHERE path = $1")
|
||||
.bind(path)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
path: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
sqlx::query!("DELETE FROM java_versions WHERE path = $1", path)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_path(
|
||||
old_path: &str,
|
||||
new_path: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
sqlx::query("UPDATE java_versions SET path = $1 WHERE path = $2")
|
||||
.bind(new_path)
|
||||
.bind(old_path)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
major_version: u32,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let version = major_version as i32;
|
||||
sqlx::query("DELETE FROM java_versions WHERE major_version = $1")
|
||||
.bind(version)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
1055
packages/app-lib/src/state/legacy_converter.rs
Normal file
1055
packages/app-lib/src/state/legacy_converter.rs
Normal file
File diff suppressed because it is too large
Load Diff
2149
packages/app-lib/src/state/minecraft_auth.rs
Normal file
2149
packages/app-lib/src/state/minecraft_auth.rs
Normal file
File diff suppressed because it is too large
Load Diff
738
packages/app-lib/src/state/minecraft_auth/yggdrasil.rs
Normal file
738
packages/app-lib/src/state/minecraft_auth/yggdrasil.rs
Normal file
@ -0,0 +1,738 @@
|
||||
use super::{
|
||||
Credentials, MinecraftAccountType, MinecraftCharacterExpressionState,
|
||||
MinecraftProfile, MinecraftSkin, MinecraftSkinVariant,
|
||||
};
|
||||
use crate::ErrorKind;
|
||||
use crate::util::fetch::INSECURE_REQWEST_CLIENT;
|
||||
use base64::Engine;
|
||||
use base64::prelude::{BASE64_STANDARD, BASE64_STANDARD_NO_PAD};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::Sqlite;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::{Duration as StdDuration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
const PENDING_LOGIN_LIFETIME: StdDuration = StdDuration::from_secs(600);
|
||||
|
||||
static PENDING_LOGINS: LazyLock<Mutex<HashMap<Uuid, PendingYggdrasilLogin>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct YggdrasilAccount {
|
||||
pub api_root: String,
|
||||
pub server_name: String,
|
||||
pub login: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub client_token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum YggdrasilLoginResult {
|
||||
Complete {
|
||||
credentials: Credentials,
|
||||
},
|
||||
SelectProfile {
|
||||
flow_id: Uuid,
|
||||
profiles: Vec<YggdrasilProfile>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct YggdrasilProfile {
|
||||
#[serde(with = "simple_uuid")]
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
mod simple_uuid {
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn serialize<S: Serializer>(
|
||||
value: &Uuid,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&value.simple().to_string())
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Uuid, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
Uuid::parse_str(&value).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct YggdrasilMetadata {
|
||||
pub api_root: String,
|
||||
pub server_name: String,
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MetadataDocument {
|
||||
#[serde(default)]
|
||||
meta: Metadata,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct Metadata {
|
||||
#[serde(rename = "serverName")]
|
||||
server_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AuthenticateResponse {
|
||||
access_token: String,
|
||||
client_token: String,
|
||||
#[serde(default)]
|
||||
available_profiles: Vec<YggdrasilProfile>,
|
||||
selected_profile: Option<YggdrasilProfile>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RefreshResponse {
|
||||
access_token: String,
|
||||
client_token: String,
|
||||
selected_profile: Option<YggdrasilProfile>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ErrorResponse {
|
||||
error: Option<String>,
|
||||
error_message: Option<String>,
|
||||
cause: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SessionProfile {
|
||||
#[serde(with = "simple_uuid")]
|
||||
id: Uuid,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
properties: Vec<SessionProperty>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SessionProperty {
|
||||
name: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TexturePayload {
|
||||
#[serde(default)]
|
||||
textures: SessionTextures,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct SessionTextures {
|
||||
#[serde(rename = "SKIN")]
|
||||
skin: Option<SessionTexture>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SessionTexture {
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
metadata: SessionTextureMetadata,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct SessionTextureMetadata {
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
struct PendingYggdrasilLogin {
|
||||
created: Instant,
|
||||
api_root: String,
|
||||
server_name: String,
|
||||
login: String,
|
||||
access_token: String,
|
||||
client_token: String,
|
||||
profiles: Vec<YggdrasilProfile>,
|
||||
}
|
||||
|
||||
pub async fn begin_yggdrasil_login(
|
||||
api_root: &str,
|
||||
login: &str,
|
||||
password: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = Sqlite> + Copy,
|
||||
) -> crate::Result<YggdrasilLoginResult> {
|
||||
let login = login.trim();
|
||||
if login.is_empty() {
|
||||
return Err(ErrorKind::InputError(
|
||||
"The Yggdrasil account name cannot be empty".to_string(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
if password.is_empty() {
|
||||
return Err(ErrorKind::InputError(
|
||||
"The Yggdrasil account password cannot be empty".to_string(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
|
||||
let metadata = fetch_yggdrasil_metadata(api_root).await?;
|
||||
let client_token = Uuid::new_v4().simple().to_string();
|
||||
let response = post_json::<AuthenticateResponse>(
|
||||
&metadata.api_root,
|
||||
"authenticate",
|
||||
json!({
|
||||
"agent": { "name": "Minecraft", "version": 1 },
|
||||
"username": login,
|
||||
"password": password,
|
||||
"clientToken": client_token,
|
||||
"requestUser": true,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(profile) = response.selected_profile {
|
||||
let credentials = create_credentials(
|
||||
profile,
|
||||
response.access_token,
|
||||
response.client_token,
|
||||
metadata,
|
||||
login,
|
||||
);
|
||||
credentials.upsert(exec).await?;
|
||||
return Ok(YggdrasilLoginResult::Complete { credentials });
|
||||
}
|
||||
|
||||
if response.available_profiles.is_empty() {
|
||||
return Err(ErrorKind::OtherError(
|
||||
"The Yggdrasil account does not have a Minecraft profile"
|
||||
.to_string(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
|
||||
if response.available_profiles.len() == 1 {
|
||||
let profile = response.available_profiles[0].clone();
|
||||
let refreshed = refresh_selected_profile(
|
||||
&metadata.api_root,
|
||||
&response.access_token,
|
||||
&response.client_token,
|
||||
&profile,
|
||||
)
|
||||
.await?;
|
||||
let selected_profile = refreshed.selected_profile.ok_or_else(|| {
|
||||
ErrorKind::OtherError(
|
||||
"The Yggdrasil service did not select the requested profile"
|
||||
.to_string(),
|
||||
)
|
||||
.as_error()
|
||||
})?;
|
||||
let credentials = create_credentials(
|
||||
selected_profile,
|
||||
refreshed.access_token,
|
||||
refreshed.client_token,
|
||||
metadata,
|
||||
login,
|
||||
);
|
||||
credentials.upsert(exec).await?;
|
||||
return Ok(YggdrasilLoginResult::Complete { credentials });
|
||||
}
|
||||
|
||||
let flow_id = Uuid::new_v4();
|
||||
let profiles = response.available_profiles.clone();
|
||||
let mut pending = PENDING_LOGINS.lock().await;
|
||||
pending.retain(|_, login| login.created.elapsed() < PENDING_LOGIN_LIFETIME);
|
||||
pending.insert(
|
||||
flow_id,
|
||||
PendingYggdrasilLogin {
|
||||
created: Instant::now(),
|
||||
api_root: metadata.api_root,
|
||||
server_name: metadata.server_name,
|
||||
login: login.to_string(),
|
||||
access_token: response.access_token,
|
||||
client_token: response.client_token,
|
||||
profiles: response.available_profiles,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(YggdrasilLoginResult::SelectProfile { flow_id, profiles })
|
||||
}
|
||||
|
||||
pub async fn finish_yggdrasil_login(
|
||||
flow_id: Uuid,
|
||||
profile_id: Uuid,
|
||||
exec: impl sqlx::Executor<'_, Database = Sqlite> + Copy,
|
||||
) -> crate::Result<Credentials> {
|
||||
let login =
|
||||
PENDING_LOGINS
|
||||
.lock()
|
||||
.await
|
||||
.remove(&flow_id)
|
||||
.ok_or_else(|| {
|
||||
ErrorKind::InputError(
|
||||
"The Yggdrasil profile selection has expired".to_string(),
|
||||
)
|
||||
.as_error()
|
||||
})?;
|
||||
|
||||
if login.created.elapsed() >= PENDING_LOGIN_LIFETIME {
|
||||
return Err(ErrorKind::InputError(
|
||||
"The Yggdrasil profile selection has expired".to_string(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
|
||||
let profile = login
|
||||
.profiles
|
||||
.into_iter()
|
||||
.find(|profile| profile.id == profile_id)
|
||||
.ok_or_else(|| {
|
||||
ErrorKind::InputError(
|
||||
"The selected Yggdrasil profile is not available".to_string(),
|
||||
)
|
||||
.as_error()
|
||||
})?;
|
||||
let refreshed = refresh_selected_profile(
|
||||
&login.api_root,
|
||||
&login.access_token,
|
||||
&login.client_token,
|
||||
&profile,
|
||||
)
|
||||
.await?;
|
||||
let selected_profile = refreshed.selected_profile.ok_or_else(|| {
|
||||
ErrorKind::OtherError(
|
||||
"The Yggdrasil service did not select the requested profile"
|
||||
.to_string(),
|
||||
)
|
||||
.as_error()
|
||||
})?;
|
||||
let credentials = create_credentials(
|
||||
selected_profile,
|
||||
refreshed.access_token,
|
||||
refreshed.client_token,
|
||||
YggdrasilMetadata {
|
||||
api_root: login.api_root,
|
||||
server_name: login.server_name,
|
||||
raw: String::new(),
|
||||
},
|
||||
&login.login,
|
||||
);
|
||||
credentials.upsert(exec).await?;
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
pub async fn refresh_yggdrasil_credentials(
|
||||
credentials: &mut Credentials,
|
||||
exec: impl sqlx::Executor<'_, Database = Sqlite> + Copy,
|
||||
) -> crate::Result<()> {
|
||||
if credentials.expires > Utc::now() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let account = credentials.yggdrasil.clone().ok_or_else(|| {
|
||||
ErrorKind::OtherError(
|
||||
"Yggdrasil credentials are missing provider information"
|
||||
.to_string(),
|
||||
)
|
||||
.as_error()
|
||||
})?;
|
||||
let validate_url = endpoint(&account.api_root, "validate")?;
|
||||
let response = INSECURE_REQWEST_CLIENT
|
||||
.post(validate_url)
|
||||
.json(&json!({
|
||||
"accessToken": credentials.access_token,
|
||||
"clientToken": account.client_token,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
credentials.expires = Utc::now() + Duration::minutes(5);
|
||||
credentials.upsert(exec).await?;
|
||||
return Ok(());
|
||||
}
|
||||
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
tracing::warn!(
|
||||
"Yggdrasil token validation for {} was rate limited",
|
||||
account.server_name
|
||||
);
|
||||
credentials.expires = Utc::now() + Duration::minutes(1);
|
||||
credentials.upsert(exec).await?;
|
||||
return Ok(());
|
||||
}
|
||||
if response.status().as_u16() != 403 && response.status().as_u16() != 401 {
|
||||
return Err(response_error(response).await);
|
||||
}
|
||||
|
||||
let refreshed = refresh_selected_profile(
|
||||
&account.api_root,
|
||||
&credentials.access_token,
|
||||
&account.client_token,
|
||||
&YggdrasilProfile {
|
||||
id: credentials.offline_profile.id,
|
||||
name: credentials.offline_profile.name.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let profile = refreshed.selected_profile.ok_or_else(|| {
|
||||
ErrorKind::OtherError(
|
||||
"The Yggdrasil service rejected the selected profile".to_string(),
|
||||
)
|
||||
.as_error()
|
||||
})?;
|
||||
|
||||
credentials.access_token = refreshed.access_token;
|
||||
credentials.offline_profile.id = profile.id;
|
||||
credentials.offline_profile.name = profile.name;
|
||||
if let Some(account) = credentials.yggdrasil.as_mut() {
|
||||
account.client_token = refreshed.client_token;
|
||||
}
|
||||
credentials.expires = Utc::now() + Duration::minutes(5);
|
||||
credentials.upsert(exec).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fetch_yggdrasil_profile(
|
||||
account: &YggdrasilAccount,
|
||||
profile_id: Uuid,
|
||||
) -> crate::Result<Option<MinecraftProfile>> {
|
||||
let mut url = Url::parse(&format!(
|
||||
"{}/sessionserver/session/minecraft/profile/{}",
|
||||
account.api_root,
|
||||
profile_id.simple()
|
||||
))?;
|
||||
url.query_pairs_mut().append_pair("unsigned", "false");
|
||||
let response = INSECURE_REQWEST_CLIENT.get(url).send().await?;
|
||||
if response.status() == reqwest::StatusCode::NO_CONTENT {
|
||||
return Ok(None);
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(response_error(response).await);
|
||||
}
|
||||
|
||||
let raw = response.text().await?;
|
||||
let profile: SessionProfile =
|
||||
serde_json::from_str(&raw).map_err(|error| {
|
||||
ErrorKind::OtherError(format!(
|
||||
"The Yggdrasil session profile is not valid JSON: {error}"
|
||||
))
|
||||
.as_error()
|
||||
})?;
|
||||
let skin = profile
|
||||
.properties
|
||||
.iter()
|
||||
.find(|property| property.name == "textures")
|
||||
.and_then(|property| match decode_session_skin(profile.id, &property.value) {
|
||||
Ok(skin) => skin,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"Unable to decode the Yggdrasil skin for profile {}: {error}",
|
||||
profile.id
|
||||
);
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Some(MinecraftProfile {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
skins: skin.into_iter().collect(),
|
||||
capes: Vec::new(),
|
||||
fetch_time: Some(Instant::now()),
|
||||
}))
|
||||
}
|
||||
|
||||
fn decode_session_skin(
|
||||
profile_id: Uuid,
|
||||
encoded_textures: &str,
|
||||
) -> Result<Option<MinecraftSkin>, String> {
|
||||
let decoded = BASE64_STANDARD
|
||||
.decode(encoded_textures)
|
||||
.or_else(|_| BASE64_STANDARD_NO_PAD.decode(encoded_textures))
|
||||
.map_err(|error| {
|
||||
format!("invalid Base64 textures property: {error}")
|
||||
})?;
|
||||
let payload: TexturePayload = serde_json::from_slice(&decoded)
|
||||
.map_err(|error| format!("invalid textures JSON: {error}"))?;
|
||||
let Some(skin) = payload.textures.skin else {
|
||||
return Ok(None);
|
||||
};
|
||||
let url = Url::parse(&skin.url)
|
||||
.map_err(|error| format!("invalid skin texture URL: {error}"))?;
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return Err("skin texture URL must use HTTP or HTTPS".to_string());
|
||||
}
|
||||
let variant = if skin.metadata.model.as_deref() == Some("slim") {
|
||||
MinecraftSkinVariant::Slim
|
||||
} else {
|
||||
MinecraftSkinVariant::Classic
|
||||
};
|
||||
|
||||
Ok(Some(MinecraftSkin {
|
||||
id: profile_id,
|
||||
state: MinecraftCharacterExpressionState::Active,
|
||||
url: Arc::new(url),
|
||||
texture_key: None,
|
||||
variant,
|
||||
name: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn fetch_yggdrasil_metadata(
|
||||
api_root: &str,
|
||||
) -> crate::Result<YggdrasilMetadata> {
|
||||
let api_root = normalize_api_root(api_root)?;
|
||||
let response = INSECURE_REQWEST_CLIENT.get(&api_root).send().await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(response_error(response).await);
|
||||
}
|
||||
let raw = response.text().await?;
|
||||
let document: MetadataDocument =
|
||||
serde_json::from_str(&raw).map_err(|error| {
|
||||
ErrorKind::InputError(format!(
|
||||
"The Yggdrasil service metadata is not valid JSON: {error}"
|
||||
))
|
||||
.as_error()
|
||||
})?;
|
||||
let fallback_name = Url::parse(&api_root)
|
||||
.ok()
|
||||
.and_then(|url| url.host_str().map(str::to_string))
|
||||
.unwrap_or_else(|| "Yggdrasil".to_string());
|
||||
let server_name = document
|
||||
.meta
|
||||
.server_name
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or(fallback_name);
|
||||
|
||||
Ok(YggdrasilMetadata {
|
||||
api_root,
|
||||
server_name,
|
||||
raw,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn normalize_api_root(api_root: &str) -> crate::Result<String> {
|
||||
let mut url = Url::parse(api_root.trim()).map_err(|error| {
|
||||
ErrorKind::InputError(format!("Invalid Yggdrasil API URL: {error}"))
|
||||
.as_error()
|
||||
})?;
|
||||
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
|
||||
return Err(ErrorKind::InputError(
|
||||
"The Yggdrasil API URL must be an HTTP or HTTPS URL".to_string(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
if url.username() != "" || url.password().is_some() {
|
||||
return Err(ErrorKind::InputError(
|
||||
"The Yggdrasil API URL cannot contain credentials".to_string(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
let path = url.path().trim_end_matches('/').to_string();
|
||||
url.set_path(&path);
|
||||
Ok(url.to_string().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn endpoint(api_root: &str, action: &str) -> crate::Result<Url> {
|
||||
Url::parse(&format!("{api_root}/authserver/{action}")).map_err(|error| {
|
||||
ErrorKind::InputError(format!("Invalid Yggdrasil endpoint: {error}"))
|
||||
.as_error()
|
||||
})
|
||||
}
|
||||
|
||||
async fn refresh_selected_profile(
|
||||
api_root: &str,
|
||||
access_token: &str,
|
||||
client_token: &str,
|
||||
profile: &YggdrasilProfile,
|
||||
) -> crate::Result<RefreshResponse> {
|
||||
post_json(
|
||||
api_root,
|
||||
"refresh",
|
||||
json!({
|
||||
"accessToken": access_token,
|
||||
"clientToken": client_token,
|
||||
"selectedProfile": profile,
|
||||
"requestUser": true,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn post_json<T: for<'de> Deserialize<'de>>(
|
||||
api_root: &str,
|
||||
action: &str,
|
||||
body: serde_json::Value,
|
||||
) -> crate::Result<T> {
|
||||
let response = INSECURE_REQWEST_CLIENT
|
||||
.post(endpoint(api_root, action)?)
|
||||
.header(reqwest::header::ACCEPT_LANGUAGE, "zh-CN")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(response_error(response).await);
|
||||
}
|
||||
Ok(response.json().await?)
|
||||
}
|
||||
|
||||
async fn response_error(response: reqwest::Response) -> crate::Error {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let message = serde_json::from_str::<ErrorResponse>(&body)
|
||||
.ok()
|
||||
.and_then(|error| {
|
||||
error
|
||||
.error_message
|
||||
.or(error.cause)
|
||||
.or(error.error)
|
||||
.filter(|message| !message.trim().is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
if body.trim().is_empty() {
|
||||
format!("The Yggdrasil service returned HTTP {status}")
|
||||
} else {
|
||||
format!("The Yggdrasil service returned HTTP {status}: {body}")
|
||||
}
|
||||
});
|
||||
ErrorKind::OtherError(message).as_error()
|
||||
}
|
||||
|
||||
fn create_credentials(
|
||||
profile: YggdrasilProfile,
|
||||
access_token: String,
|
||||
client_token: String,
|
||||
metadata: YggdrasilMetadata,
|
||||
login: &str,
|
||||
) -> Credentials {
|
||||
Credentials {
|
||||
offline_profile: MinecraftProfile {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
..MinecraftProfile::default()
|
||||
},
|
||||
account_type: MinecraftAccountType::Yggdrasil,
|
||||
access_token,
|
||||
refresh_token: String::new(),
|
||||
expires: Utc::now() + Duration::minutes(5),
|
||||
active: true,
|
||||
yggdrasil: Some(YggdrasilAccount {
|
||||
api_root: metadata.api_root,
|
||||
server_name: metadata.server_name,
|
||||
login: login.to_string(),
|
||||
client_token,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_api_roots() {
|
||||
assert_eq!(
|
||||
normalize_api_root(" https://littleskin.cn/api/yggdrasil/ ")
|
||||
.unwrap(),
|
||||
"https://littleskin.cn/api/yggdrasil"
|
||||
);
|
||||
assert!(normalize_api_root("file:///tmp/yggdrasil").is_err());
|
||||
assert!(
|
||||
normalize_api_root("https://user:pass@example.com/api").is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_profile_ids_without_hyphens() {
|
||||
let profile = YggdrasilProfile {
|
||||
id: Uuid::parse_str("01234567-89ab-cdef-0123-456789abcdef")
|
||||
.unwrap(),
|
||||
name: "Player".to_string(),
|
||||
};
|
||||
let serialized = serde_json::to_value(&profile).unwrap();
|
||||
assert_eq!(serialized["id"], "0123456789abcdef0123456789abcdef");
|
||||
assert_eq!(
|
||||
serde_json::from_value::<YggdrasilProfile>(serialized)
|
||||
.unwrap()
|
||||
.id,
|
||||
profile.id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_classic_and_slim_session_skins() {
|
||||
let profile_id = Uuid::new_v4();
|
||||
let classic = BASE64_STANDARD.encode(
|
||||
br#"{"textures":{"SKIN":{"url":"https://textures.example/skin.png"}}}"#,
|
||||
);
|
||||
let slim = BASE64_STANDARD.encode(
|
||||
br#"{"textures":{"SKIN":{"url":"https://textures.example/slim.png","metadata":{"model":"slim"}}}}"#,
|
||||
);
|
||||
|
||||
let classic =
|
||||
decode_session_skin(profile_id, &classic).unwrap().unwrap();
|
||||
let slim = decode_session_skin(profile_id, &slim).unwrap().unwrap();
|
||||
assert_eq!(classic.variant, MinecraftSkinVariant::Classic);
|
||||
assert_eq!(slim.variant, MinecraftSkinVariant::Slim);
|
||||
assert_eq!(classic.id, profile_id);
|
||||
assert_eq!(classic.url.as_str(), "https://textures.example/skin.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safely_handles_missing_or_invalid_session_skins() {
|
||||
let profile_id = Uuid::new_v4();
|
||||
let missing = BASE64_STANDARD.encode(br#"{"textures":{}}"#);
|
||||
let invalid_url = BASE64_STANDARD
|
||||
.encode(br#"{"textures":{"SKIN":{"url":"file:///tmp/skin.png"}}}"#);
|
||||
|
||||
assert!(decode_session_skin(profile_id, &missing).unwrap().is_none());
|
||||
assert!(decode_session_skin(profile_id, "not-base64").is_err());
|
||||
assert!(decode_session_skin(profile_id, &invalid_url).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persists_yggdrasil_account_metadata() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::migrate!().run(&pool).await.unwrap();
|
||||
let profile_id = Uuid::new_v4();
|
||||
let credentials = create_credentials(
|
||||
YggdrasilProfile {
|
||||
id: profile_id,
|
||||
name: "Player".to_string(),
|
||||
},
|
||||
"access-token".to_string(),
|
||||
"client-token".to_string(),
|
||||
YggdrasilMetadata {
|
||||
api_root: "https://littleskin.cn/api/yggdrasil".to_string(),
|
||||
server_name: "LittleSkin".to_string(),
|
||||
raw: "{}".to_string(),
|
||||
},
|
||||
"player@example.com",
|
||||
);
|
||||
credentials.upsert(&pool).await.unwrap();
|
||||
|
||||
let stored = Credentials::get_active_without_refresh(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(stored.is_yggdrasil());
|
||||
assert_eq!(stored.offline_profile.id, profile_id);
|
||||
let account = stored.yggdrasil.unwrap();
|
||||
assert_eq!(account.server_name, "LittleSkin");
|
||||
assert_eq!(account.login, "player@example.com");
|
||||
assert_eq!(account.client_token, "client-token");
|
||||
}
|
||||
}
|
||||
423
packages/app-lib/src/state/minecraft_skins/mod.rs
Normal file
423
packages/app-lib/src/state/minecraft_skins/mod.rs
Normal file
@ -0,0 +1,423 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use futures::{Stream, StreamExt, stream};
|
||||
use uuid::{Uuid, fmt::Hyphenated};
|
||||
|
||||
use super::MinecraftSkinVariant;
|
||||
|
||||
pub mod mojang_api;
|
||||
|
||||
/// Represents a saved skin row for a Minecraft player.
|
||||
///
|
||||
/// The same player and `texture_key` always point to the same saved skin.
|
||||
/// Changing the model variant or cape updates that saved skin instead of
|
||||
/// creating a second copy. Bundled default skins with a cape are also stored
|
||||
/// here so the cape can stay associated with the default skin card.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CustomMinecraftSkin {
|
||||
/// The key for the skin texture, which is akin to a hash that identifies it.
|
||||
pub texture_key: String,
|
||||
/// The variant of the skin model.
|
||||
pub variant: MinecraftSkinVariant,
|
||||
/// The UUID of the cape that this skin uses, which should match one of the
|
||||
/// cape UUIDs the player has in its profile.
|
||||
///
|
||||
/// If `None`, the skin is saved without a cape.
|
||||
pub cape_id: Option<Uuid>,
|
||||
/// The saved skin display order within this player's saved skins.
|
||||
pub display_order: i64,
|
||||
}
|
||||
|
||||
/// The skin selected locally for an offline Minecraft account.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OfflineMinecraftSkin {
|
||||
pub texture_key: String,
|
||||
pub variant: MinecraftSkinVariant,
|
||||
}
|
||||
|
||||
struct OfflineMinecraftSkinRow {
|
||||
texture_key: String,
|
||||
variant: MinecraftSkinVariant,
|
||||
}
|
||||
|
||||
impl OfflineMinecraftSkin {
|
||||
pub async fn get(
|
||||
minecraft_user_id: Uuid,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Option<Self>> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
|
||||
Ok(sqlx::query_as!(
|
||||
OfflineMinecraftSkinRow,
|
||||
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant' \
|
||||
FROM offline_minecraft_skins WHERE minecraft_user_uuid = ?",
|
||||
minecraft_user_id
|
||||
)
|
||||
.fetch_optional(&mut *db.acquire().await?)
|
||||
.await?
|
||||
.map(|row| Self {
|
||||
texture_key: row.texture_key,
|
||||
variant: row.variant,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn set(
|
||||
minecraft_user_id: Uuid,
|
||||
texture_key: &str,
|
||||
variant: MinecraftSkinVariant,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO offline_minecraft_skins (minecraft_user_uuid, texture_key, variant) \
|
||||
VALUES (?, ?, ?) \
|
||||
ON CONFLICT (minecraft_user_uuid) DO UPDATE SET \
|
||||
texture_key = excluded.texture_key, variant = excluded.variant",
|
||||
minecraft_user_id,
|
||||
texture_key,
|
||||
variant
|
||||
)
|
||||
.execute(&mut *db.acquire().await?)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear(
|
||||
minecraft_user_id: Uuid,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM offline_minecraft_skins WHERE minecraft_user_uuid = ?",
|
||||
minecraft_user_id
|
||||
)
|
||||
.execute(&mut *db.acquire().await?)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_if_texture(
|
||||
minecraft_user_id: Uuid,
|
||||
texture_key: &str,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM offline_minecraft_skins \
|
||||
WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
minecraft_user_id,
|
||||
texture_key
|
||||
)
|
||||
.execute(&mut *db.acquire().await?)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum CustomMinecraftSkinInsertPosition {
|
||||
Top,
|
||||
Bottom,
|
||||
At(i64),
|
||||
}
|
||||
|
||||
struct CustomMinecraftSkinRow {
|
||||
texture_key: String,
|
||||
variant: MinecraftSkinVariant,
|
||||
cape_id: Option<Hyphenated>,
|
||||
display_order: i64,
|
||||
}
|
||||
|
||||
impl CustomMinecraftSkin {
|
||||
pub async fn add(
|
||||
minecraft_user_id: Uuid,
|
||||
texture_key: &str,
|
||||
texture: &[u8],
|
||||
variant: MinecraftSkinVariant,
|
||||
cape_id: Option<Uuid>,
|
||||
insert_position: CustomMinecraftSkinInsertPosition,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
let cape_id = cape_id.map(|id| id.hyphenated());
|
||||
|
||||
let mut transaction = db.begin().await?;
|
||||
|
||||
let existing_order = sqlx::query_scalar!(
|
||||
"SELECT display_order FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
minecraft_user_id,
|
||||
texture_key
|
||||
)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let display_order = match existing_order {
|
||||
Some(display_order) => display_order,
|
||||
None => match insert_position {
|
||||
CustomMinecraftSkinInsertPosition::Top => {
|
||||
sqlx::query!(
|
||||
"UPDATE custom_minecraft_skins SET display_order = display_order + 1 WHERE minecraft_user_uuid = ?",
|
||||
minecraft_user_id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
0
|
||||
}
|
||||
CustomMinecraftSkinInsertPosition::Bottom => {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' \
|
||||
FROM custom_minecraft_skins WHERE minecraft_user_uuid = ?",
|
||||
minecraft_user_id
|
||||
)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?
|
||||
}
|
||||
CustomMinecraftSkinInsertPosition::At(display_order) => {
|
||||
sqlx::query!(
|
||||
"UPDATE custom_minecraft_skins SET display_order = display_order + 1 \
|
||||
WHERE minecraft_user_uuid = ? AND display_order >= ?",
|
||||
minecraft_user_id,
|
||||
display_order
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
display_order
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
minecraft_user_id,
|
||||
texture_key
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)",
|
||||
texture_key, texture
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id, display_order) VALUES (?, ?, ?, ?, ?)",
|
||||
minecraft_user_id, texture_key, variant, cape_id, display_order
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_by_texture(
|
||||
minecraft_user_id: Uuid,
|
||||
texture_key: &str,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Option<Self>> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
|
||||
sqlx::query_as!(
|
||||
CustomMinecraftSkinRow,
|
||||
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order \
|
||||
FROM custom_minecraft_skins \
|
||||
WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
minecraft_user_id,
|
||||
texture_key
|
||||
)
|
||||
.fetch_optional(&mut *db.acquire().await?)
|
||||
.await?
|
||||
.map(|row| {
|
||||
Ok(Self {
|
||||
texture_key: row.texture_key,
|
||||
variant: row.variant,
|
||||
cape_id: row.cape_id.map(Uuid::from),
|
||||
display_order: row.display_order,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub async fn get_many(
|
||||
minecraft_user_id: Uuid,
|
||||
offset: u32,
|
||||
count: u32,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<impl Stream<Item = Self>> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
|
||||
Ok(stream::iter(sqlx::query!(
|
||||
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated', display_order \
|
||||
FROM custom_minecraft_skins \
|
||||
WHERE minecraft_user_uuid = ? \
|
||||
ORDER BY display_order ASC, rowid ASC \
|
||||
LIMIT ? OFFSET ?",
|
||||
minecraft_user_id, count, offset
|
||||
)
|
||||
.fetch_all(&mut *db.acquire().await?)
|
||||
.await?)
|
||||
.map(|row| Self {
|
||||
texture_key: row.texture_key,
|
||||
variant: row.variant,
|
||||
cape_id: row.cape_id.map(Uuid::from),
|
||||
display_order: row.display_order,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_all(
|
||||
minecraft_user_id: Uuid,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<impl Stream<Item = Self>> {
|
||||
// Limit ourselves to 2048 skins, so that memory usage even when storing base64
|
||||
// PNG data of a 64x64 texture with random pixels stays around ~150 MiB
|
||||
Self::get_many(minecraft_user_id, 0, 2048, db).await
|
||||
}
|
||||
|
||||
pub async fn texture_blob(
|
||||
&self,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Vec<u8>> {
|
||||
Ok(sqlx::query_scalar!(
|
||||
"SELECT texture FROM custom_minecraft_skin_textures WHERE texture_key = ?",
|
||||
self.texture_key
|
||||
)
|
||||
.fetch_one(&mut *db.acquire().await?)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
&self,
|
||||
minecraft_user_id: Uuid,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
minecraft_user_id,
|
||||
self.texture_key
|
||||
)
|
||||
.execute(&mut *db.acquire().await?)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_order(
|
||||
minecraft_user_id: Uuid,
|
||||
texture_keys: &[String],
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
let mut transaction = db.begin().await?;
|
||||
|
||||
let existing_rows = sqlx::query!(
|
||||
"SELECT texture_key FROM custom_minecraft_skins \
|
||||
WHERE minecraft_user_uuid = ? \
|
||||
ORDER BY display_order ASC, rowid ASC",
|
||||
minecraft_user_id
|
||||
)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let existing_keys = existing_rows
|
||||
.iter()
|
||||
.map(|row| row.texture_key.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
let mut seen_keys = HashSet::new();
|
||||
let mut ordered_keys = Vec::with_capacity(existing_rows.len());
|
||||
|
||||
for texture_key in texture_keys {
|
||||
if seen_keys.insert(texture_key.as_str())
|
||||
&& existing_keys.contains(texture_key.as_str())
|
||||
{
|
||||
ordered_keys.push(texture_key.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
for row in &existing_rows {
|
||||
if seen_keys.insert(row.texture_key.as_str()) {
|
||||
ordered_keys.push(row.texture_key.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
for (display_order, texture_key) in ordered_keys.into_iter().enumerate()
|
||||
{
|
||||
let display_order = display_order as i64;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE custom_minecraft_skins SET display_order = ? \
|
||||
WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
display_order,
|
||||
minecraft_user_id,
|
||||
texture_key
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::Credentials;
|
||||
|
||||
#[tokio::test]
|
||||
async fn persists_offline_skin_selection_per_account() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::migrate!().run(&pool).await.unwrap();
|
||||
|
||||
let credentials = Credentials::offline("SkinTester").unwrap();
|
||||
credentials.upsert(&pool).await.unwrap();
|
||||
OfflineMinecraftSkin::set(
|
||||
credentials.offline_profile.id,
|
||||
"local-test-texture",
|
||||
MinecraftSkinVariant::Slim,
|
||||
&pool,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let selected =
|
||||
OfflineMinecraftSkin::get(credentials.offline_profile.id, &pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(selected.texture_key, "local-test-texture");
|
||||
assert_eq!(selected.variant, MinecraftSkinVariant::Slim);
|
||||
|
||||
OfflineMinecraftSkin::clear_if_texture(
|
||||
credentials.offline_profile.id,
|
||||
"local-test-texture",
|
||||
&pool,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
OfflineMinecraftSkin::get(credentials.offline_profile.id, &pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
169
packages/app-lib/src/state/minecraft_skins/mojang_api.rs
Normal file
169
packages/app-lib/src/state/minecraft_skins/mojang_api.rs
Normal file
@ -0,0 +1,169 @@
|
||||
use std::{error::Error, sync::Arc, time::Instant};
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::TryStream;
|
||||
use reqwest::{Body, multipart::Part};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::MinecraftSkinVariant;
|
||||
use crate::{
|
||||
ErrorKind,
|
||||
data::Credentials,
|
||||
state::{
|
||||
MINECRAFT_SERVICES_USER_AGENT, MinecraftProfile, PROFILE_CACHE,
|
||||
ProfileCacheEntry,
|
||||
},
|
||||
util::fetch::INSECURE_REQWEST_CLIENT,
|
||||
util::mojang::{mojang_service_url, should_use_mojang_mirror},
|
||||
};
|
||||
|
||||
/// Provides operations for interacting with capes on a Minecraft player profile.
|
||||
pub struct MinecraftCapeOperation;
|
||||
|
||||
impl MinecraftCapeOperation {
|
||||
pub async fn equip(
|
||||
credentials: &Credentials,
|
||||
cape_id: Uuid,
|
||||
) -> crate::Result<()> {
|
||||
let url = mojang_service_url(
|
||||
"https://api.minecraftservices.com/minecraft/profile/capes/active",
|
||||
should_use_mojang_mirror(),
|
||||
);
|
||||
update_profile_cache_from_response(
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.put(url.as_ref())
|
||||
.header("Content-Type", "application/json; charset=utf-8")
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
|
||||
.bearer_auth(&credentials.access_token)
|
||||
.json(&json!({
|
||||
"capeId": cape_id.hyphenated(),
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.and_then(|response| response.error_for_status())?,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> {
|
||||
let url = mojang_service_url(
|
||||
"https://api.minecraftservices.com/minecraft/profile/capes/active",
|
||||
should_use_mojang_mirror(),
|
||||
);
|
||||
update_profile_cache_from_response(
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.delete(url.as_ref())
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
|
||||
.bearer_auth(&credentials.access_token)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|response| response.error_for_status())?,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides operations for interacting with skins on a Minecraft player profile.
|
||||
pub struct MinecraftSkinOperation;
|
||||
|
||||
impl MinecraftSkinOperation {
|
||||
pub async fn equip<TextureStream>(
|
||||
credentials: &Credentials,
|
||||
texture: TextureStream,
|
||||
variant: MinecraftSkinVariant,
|
||||
) -> crate::Result<Option<Arc<MinecraftProfile>>>
|
||||
where
|
||||
TextureStream: TryStream + Send + 'static,
|
||||
TextureStream::Error: Into<Box<dyn Error + Send + Sync>>,
|
||||
Bytes: From<TextureStream::Ok>,
|
||||
{
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.text(
|
||||
"variant",
|
||||
match variant {
|
||||
MinecraftSkinVariant::Slim => "slim",
|
||||
MinecraftSkinVariant::Classic => "classic",
|
||||
_ => {
|
||||
return Err(ErrorKind::OtherError(
|
||||
"Cannot equip skin of unknown model variant".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
},
|
||||
)
|
||||
.part(
|
||||
"file",
|
||||
Part::stream(Body::wrap_stream(texture))
|
||||
.mime_str("image/png")?
|
||||
.file_name("skin.png"),
|
||||
);
|
||||
|
||||
let url = mojang_service_url(
|
||||
"https://api.minecraftservices.com/minecraft/profile/skins",
|
||||
should_use_mojang_mirror(),
|
||||
);
|
||||
let profile = update_profile_cache_from_response(
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.post(url.as_ref())
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
|
||||
.bearer_auth(&credentials.access_token)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|response| response.error_for_status())?,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> {
|
||||
let url = mojang_service_url(
|
||||
"https://api.minecraftservices.com/minecraft/profile/skins/active",
|
||||
should_use_mojang_mirror(),
|
||||
);
|
||||
update_profile_cache_from_response(
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.delete(url.as_ref())
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
|
||||
.bearer_auth(&credentials.access_token)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|response| response.error_for_status())?,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_profile_cache_from_response(
|
||||
response: reqwest::Response,
|
||||
) -> Option<Arc<MinecraftProfile>> {
|
||||
let Some(mut profile) = response.json::<MinecraftProfile>().await.ok()
|
||||
else {
|
||||
tracing::warn!(
|
||||
"Failed to parse player profile from skin or cape operation response, not updating profile cache"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
|
||||
profile.fetch_time = Some(Instant::now());
|
||||
let profile = Arc::new(profile);
|
||||
|
||||
PROFILE_CACHE
|
||||
.lock()
|
||||
.await
|
||||
.insert(profile.id, ProfileCacheEntry::Hit(Arc::clone(&profile)));
|
||||
|
||||
Some(profile)
|
||||
}
|
||||
1201
packages/app-lib/src/state/mod.rs
Normal file
1201
packages/app-lib/src/state/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
240
packages/app-lib/src/state/mr_auth.rs
Normal file
240
packages/app-lib/src/state/mr_auth.rs
Normal file
@ -0,0 +1,240 @@
|
||||
use crate::state::{CacheBehaviour, CachedEntry};
|
||||
use crate::util::fetch::{FetchSemaphore, fetch_advanced};
|
||||
use chrono::{DateTime, Duration, TimeZone, Utc};
|
||||
use dashmap::DashMap;
|
||||
use futures::TryStreamExt;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ModrinthCredentials {
|
||||
pub session: String,
|
||||
pub expires: DateTime<Utc>,
|
||||
pub user_id: String,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
impl ModrinthCredentials {
|
||||
pub async fn get_and_refresh(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<Option<Self>> {
|
||||
let creds = Self::get_active(exec).await?;
|
||||
|
||||
if let Some(mut creds) = creds {
|
||||
if creds.expires < Utc::now() {
|
||||
#[derive(Deserialize)]
|
||||
struct Session {
|
||||
session: String,
|
||||
}
|
||||
|
||||
let resp = fetch_advanced(
|
||||
Method::POST,
|
||||
concat!(env!("MODRINTH_API_URL"), "session/refresh"),
|
||||
None,
|
||||
None,
|
||||
Some(("Authorization", &*creds.session)),
|
||||
None,
|
||||
None,
|
||||
Some("/v2/session/refresh"),
|
||||
semaphore,
|
||||
exec,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|resp| serde_json::from_slice::<Session>(&resp).ok());
|
||||
|
||||
if let Some(value) = resp {
|
||||
creds.session = value.session;
|
||||
creds.expires = Utc::now() + Duration::weeks(2);
|
||||
creds.upsert(exec).await?;
|
||||
|
||||
Ok(Some(creds))
|
||||
} else {
|
||||
Self::remove(&creds.user_id, exec).await?;
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
Ok(Some(creds))
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_active(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Option<Self>> {
|
||||
let res = sqlx::query!(
|
||||
"
|
||||
SELECT
|
||||
id, active, session_id, expires
|
||||
FROM modrinth_users
|
||||
WHERE active = TRUE
|
||||
"
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
.await?;
|
||||
|
||||
Ok(res.map(|x| Self {
|
||||
session: x.session_id,
|
||||
expires: Utc
|
||||
.timestamp_opt(x.expires, 0)
|
||||
.single()
|
||||
.unwrap_or_else(Utc::now),
|
||||
user_id: x.id,
|
||||
active: x.active == 1,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_all(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<DashMap<String, Self>> {
|
||||
let res = sqlx::query!(
|
||||
"
|
||||
SELECT
|
||||
id, active, session_id, expires
|
||||
FROM modrinth_users
|
||||
"
|
||||
)
|
||||
.fetch(exec)
|
||||
.try_fold(DashMap::new(), |acc, x| {
|
||||
acc.insert(
|
||||
x.id.clone(),
|
||||
Self {
|
||||
session: x.session_id,
|
||||
expires: Utc
|
||||
.timestamp_opt(x.expires, 0)
|
||||
.single()
|
||||
.unwrap_or_else(Utc::now),
|
||||
user_id: x.id,
|
||||
active: x.active == 1,
|
||||
},
|
||||
);
|
||||
|
||||
async move { Ok(acc) }
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn upsert(
|
||||
&self,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
) -> crate::Result<()> {
|
||||
let expires = self.expires.timestamp();
|
||||
|
||||
if self.active {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE modrinth_users
|
||||
SET active = FALSE
|
||||
"
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO modrinth_users (id, active, session_id, expires)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
active = $2,
|
||||
session_id = $3,
|
||||
expires = $4
|
||||
",
|
||||
self.user_id,
|
||||
self.active,
|
||||
self.session,
|
||||
expires,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
user_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM modrinth_users WHERE id = $1
|
||||
",
|
||||
user_id,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_all() -> crate::Result<()> {
|
||||
let state = crate::State::get().await?;
|
||||
let all = Self::get_all(&state.pool).await?;
|
||||
|
||||
let user_ids = all.into_iter().map(|x| x.0).collect::<Vec<_>>();
|
||||
|
||||
CachedEntry::get_user_many(
|
||||
&user_ids.iter().map(|x| &**x).collect::<Vec<_>>(),
|
||||
Some(CacheBehaviour::Bypass),
|
||||
&state.pool,
|
||||
&state.fetch_semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn get_login_url() -> &'static str {
|
||||
concat!(env!("MODRINTH_URL"), "auth/sign-in")
|
||||
}
|
||||
|
||||
pub async fn finish_login_flow(
|
||||
code: &str,
|
||||
semaphore: &FetchSemaphore,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<ModrinthCredentials> {
|
||||
// The authorization code actually is the access token, since labrinth doesn't
|
||||
// issue separate authorization codes. Therefore, this is equivalent to an
|
||||
// implicit OAuth grant flow, and no additional exchanging or finalization is
|
||||
// needed. TODO not do this for the reasons outlined at
|
||||
// https://oauth.net/2/grant-types/implicit/
|
||||
|
||||
let info = fetch_info(code, semaphore, exec).await?;
|
||||
|
||||
Ok(ModrinthCredentials {
|
||||
session: code.to_string(),
|
||||
expires: Utc::now() + Duration::weeks(2),
|
||||
user_id: info.id,
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_info(
|
||||
token: &str,
|
||||
semaphore: &FetchSemaphore,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<crate::state::cache::User> {
|
||||
let result = fetch_advanced(
|
||||
Method::GET,
|
||||
concat!(env!("MODRINTH_API_URL"), "user"),
|
||||
None,
|
||||
None,
|
||||
Some(("Authorization", token)),
|
||||
None,
|
||||
None,
|
||||
Some("/v2/user"),
|
||||
semaphore,
|
||||
exec,
|
||||
)
|
||||
.await?;
|
||||
let value = serde_json::from_slice(&result)?;
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
1359
packages/app-lib/src/state/process.rs
Normal file
1359
packages/app-lib/src/state/process.rs
Normal file
File diff suppressed because it is too large
Load Diff
41
packages/app-lib/src/state/proxy_settings.rs
Normal file
41
packages/app-lib/src/state/proxy_settings.rs
Normal file
@ -0,0 +1,41 @@
|
||||
//! Persistence for the user-configurable proxy settings.
|
||||
|
||||
use sqlx::{Executor, Row, Sqlite};
|
||||
|
||||
use crate::util::proxy::{ProxyConfig, ProxyMode};
|
||||
|
||||
const PROXY_COLUMNS_QUERY: &str = "\
|
||||
SELECT proxy_mode, proxy_url, proxy_username, proxy_password \
|
||||
FROM settings WHERE id = 0";
|
||||
|
||||
pub async fn get<'a, E>(exec: E) -> crate::Result<ProxyConfig>
|
||||
where
|
||||
E: Executor<'a, Database = Sqlite> + Copy,
|
||||
{
|
||||
let row = sqlx::query(PROXY_COLUMNS_QUERY).fetch_one(exec).await?;
|
||||
Ok(ProxyConfig {
|
||||
mode: ProxyMode::from_string(&row.get::<String, _>("proxy_mode")),
|
||||
url: row.get::<String, _>("proxy_url"),
|
||||
username: row.get::<String, _>("proxy_username"),
|
||||
password: row.get::<String, _>("proxy_password"),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set<'a, E>(exec: E, config: &ProxyConfig) -> crate::Result<()>
|
||||
where
|
||||
E: Executor<'a, Database = Sqlite> + Copy,
|
||||
{
|
||||
config.validate()?;
|
||||
sqlx::query(
|
||||
"UPDATE settings \
|
||||
SET proxy_mode = ?, proxy_url = ?, proxy_username = ?, proxy_password = ? \
|
||||
WHERE id = 0",
|
||||
)
|
||||
.bind(config.mode.as_str())
|
||||
.bind(config.url.trim())
|
||||
.bind(config.username.trim())
|
||||
.bind(config.password.clone())
|
||||
.execute(exec)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
68
packages/app-lib/src/state/server_join_log.rs
Normal file
68
packages/app-lib/src/state/server_join_log.rs
Normal file
@ -0,0 +1,68 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct JoinLogEntry {
|
||||
pub instance_id: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub join_time: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl JoinLogEntry {
|
||||
pub async fn upsert(
|
||||
&self,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let join_time = self.join_time.timestamp();
|
||||
let instance_id = self.instance_id.as_str();
|
||||
let host = self.host.as_str();
|
||||
let port = i64::from(self.port);
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO join_log (instance_id, host, port, join_time)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (instance_id, host, port) DO UPDATE SET
|
||||
join_time = excluded.join_time
|
||||
",
|
||||
instance_id,
|
||||
host,
|
||||
port,
|
||||
join_time,
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_joins(
|
||||
instance_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<HashMap<(String, u16), DateTime<Utc>>> {
|
||||
let joins = sqlx::query!(
|
||||
"
|
||||
SELECT host, port, join_time
|
||||
FROM join_log
|
||||
WHERE instance_id = ?
|
||||
",
|
||||
instance_id,
|
||||
)
|
||||
.fetch_all(exec)
|
||||
.await?;
|
||||
|
||||
Ok(joins
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
(
|
||||
(row.host, row.port as u16),
|
||||
Utc.timestamp_opt(row.join_time, 0)
|
||||
.single()
|
||||
.unwrap_or_else(Utc::now),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
1415
packages/app-lib/src/state/settings.rs
Normal file
1415
packages/app-lib/src/state/settings.rs
Normal file
File diff suppressed because it is too large
Load Diff
61
packages/app-lib/src/state/tunnel.rs
Normal file
61
packages/app-lib/src/state/tunnel.rs
Normal file
@ -0,0 +1,61 @@
|
||||
use crate::state::FriendsSocket;
|
||||
use crate::state::friends::{TunnelSockets, WriteSocket};
|
||||
use ariadne::networking::message::ClientToServerMessage;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::tcp::OwnedWriteHalf;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(super) enum InternalTunnelSocket {
|
||||
Listening(SocketAddr),
|
||||
Connected(Mutex<OwnedWriteHalf>),
|
||||
}
|
||||
|
||||
pub struct TunnelSocket {
|
||||
pub(super) socket_id: Uuid,
|
||||
pub(super) write: WriteSocket,
|
||||
pub(super) sockets: TunnelSockets,
|
||||
pub(super) internal: Arc<InternalTunnelSocket>,
|
||||
}
|
||||
|
||||
impl TunnelSocket {
|
||||
pub fn socket_id(&self) -> Uuid {
|
||||
self.socket_id
|
||||
}
|
||||
|
||||
pub async fn shutdown(self) -> crate::Result<()> {
|
||||
if self.sockets.remove(&self.socket_id).is_some() {
|
||||
FriendsSocket::send_message(
|
||||
&self.write,
|
||||
ClientToServerMessage::SocketClose {
|
||||
socket: self.socket_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if let InternalTunnelSocket::Connected(ref stream) =
|
||||
*self.internal.clone()
|
||||
{
|
||||
stream.lock().await.shutdown().await?
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TunnelSocket {
|
||||
fn drop(&mut self) {
|
||||
if self.sockets.remove(&self.socket_id).is_some() {
|
||||
let write = self.write.clone();
|
||||
let socket_id = self.socket_id;
|
||||
tokio::spawn(async move {
|
||||
let _ = FriendsSocket::send_message(
|
||||
&write,
|
||||
ClientToServerMessage::SocketClose { socket: socket_id },
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user