From 16e2abaaa7a4ab1166e20f0e5a2702fa8b4a3629 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Thu, 11 Jun 2026 15:50:39 +0100 Subject: [PATCH 1/9] feat: base of instances v2 --- ...611120000_instances-content-foundation.sql | 379 ++++++++++++++++ .../src/state/instances/adapters/mod.rs | 1 + .../instances/adapters/sqlite/content_rows.rs | 393 ++++++++++++++++ .../adapters/sqlite/instance_rows.rs | 419 ++++++++++++++++++ .../state/instances/adapters/sqlite/mod.rs | 2 + .../src/state/instances/commands/mod.rs | 1 + .../app-lib/src/state/instances/content.rs | 2 + .../app-lib/src/state/instances/domain/mod.rs | 1 + packages/app-lib/src/state/instances/ids.rs | 21 + .../app-lib/src/state/instances/legacy/mod.rs | 1 + packages/app-lib/src/state/instances/mod.rs | 13 +- .../state/instances/model/content_entry.rs | 52 +++ .../src/state/instances/model/content_set.rs | 87 ++++ .../instances/model/content_set_remote_ref.rs | 34 ++ .../instances/model/content_set_sync_state.rs | 74 ++++ .../app-lib/src/state/instances/model/file.rs | 16 + .../src/state/instances/model/instance.rs | 22 + .../src/state/instances/model/launch.rs | 14 + .../app-lib/src/state/instances/model/link.rs | 35 ++ .../src/state/instances/model/manifest.rs | 11 + .../app-lib/src/state/instances/model/mod.rs | 34 ++ .../src/state/instances/model/update_check.rs | 11 + 22 files changed, 1621 insertions(+), 2 deletions(-) create mode 100644 packages/app-lib/migrations/20260611120000_instances-content-foundation.sql create mode 100644 packages/app-lib/src/state/instances/adapters/mod.rs create mode 100644 packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs create mode 100644 packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs create mode 100644 packages/app-lib/src/state/instances/adapters/sqlite/mod.rs create mode 100644 packages/app-lib/src/state/instances/commands/mod.rs create mode 100644 packages/app-lib/src/state/instances/domain/mod.rs create mode 100644 packages/app-lib/src/state/instances/ids.rs create mode 100644 packages/app-lib/src/state/instances/legacy/mod.rs create mode 100644 packages/app-lib/src/state/instances/model/content_entry.rs create mode 100644 packages/app-lib/src/state/instances/model/content_set.rs create mode 100644 packages/app-lib/src/state/instances/model/content_set_remote_ref.rs create mode 100644 packages/app-lib/src/state/instances/model/content_set_sync_state.rs create mode 100644 packages/app-lib/src/state/instances/model/file.rs create mode 100644 packages/app-lib/src/state/instances/model/instance.rs create mode 100644 packages/app-lib/src/state/instances/model/launch.rs create mode 100644 packages/app-lib/src/state/instances/model/link.rs create mode 100644 packages/app-lib/src/state/instances/model/manifest.rs create mode 100644 packages/app-lib/src/state/instances/model/mod.rs create mode 100644 packages/app-lib/src/state/instances/model/update_check.rs diff --git a/packages/app-lib/migrations/20260611120000_instances-content-foundation.sql b/packages/app-lib/migrations/20260611120000_instances-content-foundation.sql new file mode 100644 index 0000000000..461f61a342 --- /dev/null +++ b/packages/app-lib/migrations/20260611120000_instances-content-foundation.sql @@ -0,0 +1,379 @@ +CREATE TABLE instances ( + id TEXT NOT NULL, + path TEXT NOT NULL, + applied_content_set_id TEXT NULL, + + install_stage TEXT NOT NULL, + launcher_feature_version TEXT NOT NULL, + update_channel TEXT NOT NULL DEFAULT 'release', + + name TEXT NOT NULL, + icon_path TEXT NULL, + + created INTEGER NOT NULL, + modified INTEGER NOT NULL, + last_played INTEGER NULL, + + submitted_time_played INTEGER NOT NULL DEFAULT 0, + recent_time_played INTEGER NOT NULL DEFAULT 0, + + PRIMARY KEY (id), + UNIQUE (path) +); + +CREATE INDEX instances_path ON instances(path); +CREATE INDEX instances_applied_content_set_id + ON instances(applied_content_set_id); + +CREATE TABLE instance_links ( + instance_id TEXT NOT NULL, + link_kind TEXT NOT NULL, + + modrinth_project_id TEXT NULL, + modrinth_version_id TEXT NULL, + + server_project_id TEXT NULL, + + content_project_id TEXT NULL, + content_version_id TEXT NULL, + + hosting_server_id TEXT NULL, + hosting_instance_ids JSONB NULL, + hosting_active_instance_id TEXT NULL, + + shared_instance_id TEXT NULL, + + PRIMARY KEY (instance_id), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE +); + +CREATE INDEX instance_links_link_kind ON instance_links(link_kind); +CREATE INDEX instance_links_modrinth_project_id + ON instance_links(modrinth_project_id); +CREATE INDEX instance_links_server_project_id + ON instance_links(server_project_id); +CREATE INDEX instance_links_hosting_server_id + ON instance_links(hosting_server_id); +CREATE INDEX instance_links_hosting_active_instance_id + ON instance_links(hosting_active_instance_id); +CREATE INDEX instance_links_shared_instance_id + ON instance_links(shared_instance_id); + +CREATE TABLE instance_groups ( + instance_id TEXT NOT NULL, + group_name TEXT NOT NULL, + + PRIMARY KEY (instance_id, group_name), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE +); + +CREATE INDEX instance_groups_group_name ON instance_groups(group_name); + +CREATE TABLE instance_launch_overrides ( + instance_id TEXT NOT NULL, + + java_path TEXT NULL, + extra_launch_args JSONB NULL, + custom_env_vars JSONB NULL, + + memory INTEGER NULL, + force_fullscreen INTEGER NULL, + game_resolution_x INTEGER NULL, + game_resolution_y INTEGER NULL, + + hook_pre_launch TEXT NULL, + hook_wrapper TEXT NULL, + hook_post_exit TEXT NULL, + + PRIMARY KEY (instance_id), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE +); + +CREATE TABLE instance_content_sets ( + id TEXT NOT NULL, + instance_id TEXT NOT NULL, + + name TEXT NOT NULL, + source_kind TEXT NOT NULL, + status TEXT NOT NULL, + + game_version TEXT NOT NULL, + protocol_version INTEGER NULL, + loader TEXT NOT NULL, + loader_version TEXT NULL, + + created INTEGER NOT NULL, + modified INTEGER NOT NULL, + + PRIMARY KEY (id), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE +); + +CREATE INDEX instance_content_sets_instance_id + ON instance_content_sets(instance_id); + +CREATE TABLE instance_content_set_remote_refs ( + content_set_id TEXT NOT NULL, + ref_type TEXT NOT NULL, + ref_id TEXT NOT NULL, + + PRIMARY KEY (content_set_id, ref_type), + FOREIGN KEY (content_set_id) + REFERENCES instance_content_sets(id) + ON DELETE CASCADE +); + +CREATE INDEX instance_content_set_remote_refs_ref + ON instance_content_set_remote_refs(ref_type, ref_id); + +CREATE TABLE instance_content_set_sync_state ( + content_set_id TEXT NOT NULL, + provider TEXT NOT NULL, + + applied_update_id TEXT NULL, + latest_available_update_id TEXT NULL, + checked_at INTEGER NULL, + status TEXT NOT NULL, + + PRIMARY KEY (content_set_id), + FOREIGN KEY (content_set_id) + REFERENCES instance_content_sets(id) + ON DELETE CASCADE +); + +CREATE INDEX instance_content_set_sync_state_provider + ON instance_content_set_sync_state(provider); + +CREATE INDEX instance_content_set_sync_state_latest_available_update_id + ON instance_content_set_sync_state(latest_available_update_id); + +CREATE TABLE instance_files ( + id TEXT NOT NULL, + instance_id TEXT NOT NULL, + + relative_path TEXT NOT NULL, + file_name TEXT NOT NULL, + enabled INTEGER NOT NULL, + + sha1 TEXT NOT NULL, + size INTEGER NOT NULL, + missing INTEGER NOT NULL DEFAULT 0, + + added_at INTEGER NOT NULL, + modified_at INTEGER NOT NULL, + + PRIMARY KEY (id), + UNIQUE (instance_id, relative_path), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE +); + +CREATE INDEX instance_files_instance_id ON instance_files(instance_id); +CREATE INDEX instance_files_sha1 ON instance_files(sha1); +CREATE INDEX instance_files_missing ON instance_files(missing); + +CREATE TABLE instance_content_entries ( + id TEXT NOT NULL, + instance_id TEXT NOT NULL, + content_set_id TEXT NOT NULL, + file_id TEXT NULL, + + project_type TEXT NOT NULL, + project_id TEXT NULL, + version_id TEXT NULL, + + source_kind TEXT NOT NULL, + server_requirement TEXT NOT NULL, + client_requirement TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + + added_at INTEGER NOT NULL, + modified_at INTEGER NOT NULL, + + PRIMARY KEY (id), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE, + FOREIGN KEY (content_set_id) + REFERENCES instance_content_sets(id) + ON DELETE CASCADE, + FOREIGN KEY (file_id) REFERENCES instance_files(id) ON DELETE SET NULL +); + +CREATE INDEX instance_content_entries_instance_id + ON instance_content_entries(instance_id); +CREATE INDEX instance_content_entries_content_set_id + ON instance_content_entries(content_set_id); +CREATE INDEX instance_content_entries_file_id + ON instance_content_entries(file_id); +CREATE INDEX instance_content_entries_project_id + ON instance_content_entries(project_id); +CREATE INDEX instance_content_entries_version_id + ON instance_content_entries(version_id); +CREATE INDEX instance_content_entries_source_kind + ON instance_content_entries(source_kind); + +CREATE TABLE instance_content_update_checks ( + content_entry_id TEXT NOT NULL, + + update_channel TEXT NOT NULL, + update_version_id TEXT NULL, + checked_at INTEGER NOT NULL, + + PRIMARY KEY (content_entry_id), + FOREIGN KEY (content_entry_id) + REFERENCES instance_content_entries(id) + ON DELETE CASCADE +); + +CREATE INDEX instance_content_update_checks_update_version_id + ON instance_content_update_checks(update_version_id); + +INSERT INTO instances ( + id, + path, + applied_content_set_id, + install_stage, + launcher_feature_version, + update_channel, + name, + icon_path, + created, + modified, + last_played, + submitted_time_played, + recent_time_played +) +SELECT + 'legacy:' || path, + path, + 'legacy:' || path || ':default', + install_stage, + launcher_feature_version, + preferred_update_channel, + name, + icon_path, + created, + modified, + last_played, + submitted_time_played, + recent_time_played +FROM profiles; + +INSERT INTO instance_content_sets ( + id, + instance_id, + name, + source_kind, + status, + game_version, + protocol_version, + loader, + loader_version, + created, + modified +) +SELECT + 'legacy:' || path || ':default', + 'legacy:' || path, + 'Default', + CASE + WHEN linked_project_id IS NOT NULL + AND linked_version_id IS NOT NULL + AND linked_version_id != '' + THEN 'modrinth_modpack' + WHEN linked_project_id IS NOT NULL + AND (linked_version_id IS NULL OR linked_version_id = '') + THEN 'server_project' + ELSE 'local' + END, + 'available', + game_version, + protocol_version, + mod_loader, + mod_loader_version, + created, + modified +FROM profiles; + +INSERT INTO instance_links ( + instance_id, + link_kind, + modrinth_project_id, + modrinth_version_id, + server_project_id, + content_project_id, + content_version_id, + hosting_server_id, + hosting_instance_ids, + hosting_active_instance_id, + shared_instance_id +) +SELECT + 'legacy:' || path, + CASE + WHEN linked_project_id IS NULL + THEN 'unmanaged' + WHEN linked_version_id IS NULL OR linked_version_id = '' + THEN 'server_project' + ELSE 'modrinth_modpack' + END, + CASE + WHEN linked_version_id IS NOT NULL AND linked_version_id != '' + THEN linked_project_id + ELSE NULL + END, + CASE + WHEN linked_version_id IS NOT NULL AND linked_version_id != '' + THEN linked_version_id + ELSE NULL + END, + CASE + WHEN linked_project_id IS NOT NULL + AND (linked_version_id IS NULL OR linked_version_id = '') + THEN linked_project_id + ELSE NULL + END, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL +FROM profiles; + +INSERT OR IGNORE INTO instance_groups (instance_id, group_name) +SELECT + 'legacy:' || profiles.path, + json_each.value +FROM profiles, json_each(profiles.groups); + +INSERT INTO instance_launch_overrides ( + instance_id, + java_path, + extra_launch_args, + custom_env_vars, + memory, + force_fullscreen, + game_resolution_x, + game_resolution_y, + hook_pre_launch, + hook_wrapper, + hook_post_exit +) +SELECT + 'legacy:' || path, + override_java_path, + CASE + WHEN json_type(override_extra_launch_args) = 'null' THEN NULL + ELSE override_extra_launch_args + END, + CASE + WHEN json_type(override_custom_env_vars) = 'null' THEN NULL + ELSE override_custom_env_vars + END, + override_mc_memory_max, + override_mc_force_fullscreen, + override_mc_game_resolution_x, + override_mc_game_resolution_y, + override_hook_pre_launch, + override_hook_wrapper, + override_hook_post_exit +FROM profiles; diff --git a/packages/app-lib/src/state/instances/adapters/mod.rs b/packages/app-lib/src/state/instances/adapters/mod.rs new file mode 100644 index 0000000000..558e94c12b --- /dev/null +++ b/packages/app-lib/src/state/instances/adapters/mod.rs @@ -0,0 +1 @@ +pub(crate) mod sqlite; diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs new file mode 100644 index 0000000000..6940536d46 --- /dev/null +++ b/packages/app-lib/src/state/instances/adapters/sqlite/content_rows.rs @@ -0,0 +1,393 @@ +#![allow(dead_code)] + +use crate::state::instances::{ + ContentEntry, ContentRequirement, ContentSet, ContentSetRemoteRef, + ContentSetRemoteRefType, ContentSetStatus, ContentSetSyncProvider, + ContentSetSyncState, ContentSetSyncStatus, ContentSourceKind, + ContentUpdateCheck, InstanceFile, +}; +use crate::state::{ModLoader, ProjectType, ReleaseChannel}; +use chrono::{DateTime, TimeZone, Utc}; +use sqlx::{Executor, Sqlite}; + +#[derive(Debug, sqlx::FromRow)] +pub(crate) struct ContentSetRow { + pub id: String, + pub instance_id: String, + pub name: String, + pub source_kind: String, + pub status: String, + pub game_version: String, + pub protocol_version: Option, + pub loader: String, + pub loader_version: Option, + pub created: i64, + pub modified: i64, +} + +impl TryFrom for ContentSet { + type Error = crate::Error; + + fn try_from(row: ContentSetRow) -> crate::Result { + Ok(Self { + id: row.id, + instance_id: row.instance_id, + name: row.name, + source_kind: ContentSourceKind::from_str(&row.source_kind)?, + status: ContentSetStatus::from_str(&row.status)?, + game_version: row.game_version, + protocol_version: row.protocol_version.map(|value| value as u32), + loader: ModLoader::from_string(&row.loader), + loader_version: row.loader_version, + created: timestamp(row.created), + modified: timestamp(row.modified), + }) + } +} + +#[derive(Debug, sqlx::FromRow)] +pub(crate) struct ContentSetRemoteRefRow { + pub content_set_id: String, + pub ref_type: String, + pub ref_id: String, +} + +impl TryFrom for ContentSetRemoteRef { + type Error = crate::Error; + + fn try_from(row: ContentSetRemoteRefRow) -> crate::Result { + Ok(Self { + content_set_id: row.content_set_id, + ref_type: ContentSetRemoteRefType::from_str(&row.ref_type)?, + ref_id: row.ref_id, + }) + } +} + +#[derive(Debug, sqlx::FromRow)] +pub(crate) struct ContentSetSyncStateRow { + pub content_set_id: String, + pub provider: String, + pub applied_update_id: Option, + pub latest_available_update_id: Option, + pub checked_at: Option, + pub status: String, +} + +impl TryFrom for ContentSetSyncState { + type Error = crate::Error; + + fn try_from(row: ContentSetSyncStateRow) -> crate::Result { + Ok(Self { + content_set_id: row.content_set_id, + provider: ContentSetSyncProvider::from_str(&row.provider)?, + applied_update_id: row.applied_update_id, + latest_available_update_id: row.latest_available_update_id, + checked_at: row.checked_at.and_then(optional_timestamp), + status: ContentSetSyncStatus::from_str(&row.status)?, + }) + } +} + +#[derive(Debug, sqlx::FromRow)] +pub(crate) struct InstanceFileRow { + pub id: String, + pub instance_id: String, + pub relative_path: String, + pub file_name: String, + pub enabled: i64, + pub sha1: String, + pub size: i64, + pub missing: i64, + pub added_at: i64, + pub modified_at: i64, +} + +impl TryFrom for InstanceFile { + type Error = crate::Error; + + fn try_from(row: InstanceFileRow) -> crate::Result { + Ok(Self { + id: row.id, + instance_id: row.instance_id, + relative_path: row.relative_path, + file_name: row.file_name, + enabled: row.enabled == 1, + sha1: row.sha1, + size: unsigned(row.size, "size")?, + missing: row.missing == 1, + added_at: timestamp(row.added_at), + modified_at: timestamp(row.modified_at), + }) + } +} + +#[derive(Debug, sqlx::FromRow)] +pub(crate) struct ContentEntryRow { + pub id: String, + pub instance_id: String, + pub content_set_id: String, + pub file_id: Option, + pub project_type: String, + pub project_id: Option, + pub version_id: Option, + pub source_kind: String, + pub server_requirement: String, + pub client_requirement: String, + pub enabled: i64, + pub added_at: i64, + pub modified_at: i64, +} + +impl TryFrom for ContentEntry { + type Error = crate::Error; + + fn try_from(row: ContentEntryRow) -> crate::Result { + Ok(Self { + id: row.id, + instance_id: row.instance_id, + content_set_id: row.content_set_id, + file_id: row.file_id, + project_type: project_type_from_str(&row.project_type)?, + project_id: row.project_id, + version_id: row.version_id, + source_kind: ContentSourceKind::from_str(&row.source_kind)?, + server_requirement: ContentRequirement::from_str( + &row.server_requirement, + )?, + client_requirement: ContentRequirement::from_str( + &row.client_requirement, + )?, + enabled: row.enabled == 1, + added_at: timestamp(row.added_at), + modified_at: timestamp(row.modified_at), + }) + } +} + +#[derive(Debug, sqlx::FromRow)] +pub(crate) struct ContentUpdateCheckRow { + pub content_entry_id: String, + pub update_channel: String, + pub update_version_id: Option, + pub checked_at: i64, +} + +impl From for ContentUpdateCheck { + fn from(row: ContentUpdateCheckRow) -> Self { + Self { + content_entry_id: row.content_entry_id, + update_channel: ReleaseChannel::from_key(&row.update_channel), + update_version_id: row.update_version_id, + checked_at: timestamp(row.checked_at), + } + } +} + +pub(crate) async fn get_applied_content_set<'e, E>( + instance_id: &str, + exec: E, +) -> crate::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + let row = sqlx::query_as::<_, ContentSetRow>( + " + SELECT cs.* + FROM instances i + INNER JOIN instance_content_sets cs + ON cs.id = i.applied_content_set_id + WHERE i.id = ? + ", + ) + .bind(instance_id) + .fetch_optional(exec) + .await?; + + row.map(TryInto::try_into).transpose() +} + +pub(crate) async fn get_content_set<'e, E>( + content_set_id: &str, + exec: E, +) -> crate::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + let row = sqlx::query_as::<_, ContentSetRow>( + " + SELECT * + FROM instance_content_sets + WHERE id = ? + ", + ) + .bind(content_set_id) + .fetch_optional(exec) + .await?; + + row.map(TryInto::try_into).transpose() +} + +pub(crate) async fn get_content_sets_for_instance<'e, E>( + instance_id: &str, + exec: E, +) -> crate::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + let rows = sqlx::query_as::<_, ContentSetRow>( + " + SELECT * + FROM instance_content_sets + WHERE instance_id = ? + ORDER BY created ASC, id ASC + ", + ) + .bind(instance_id) + .fetch_all(exec) + .await?; + + rows.into_iter().map(TryInto::try_into).collect() +} + +pub(crate) async fn get_content_set_remote_refs<'e, E>( + content_set_id: &str, + exec: E, +) -> crate::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + let rows = sqlx::query_as::<_, ContentSetRemoteRefRow>( + " + SELECT * + FROM instance_content_set_remote_refs + WHERE content_set_id = ? + ORDER BY ref_type ASC + ", + ) + .bind(content_set_id) + .fetch_all(exec) + .await?; + + rows.into_iter().map(TryInto::try_into).collect() +} + +pub(crate) async fn get_content_set_sync_state<'e, E>( + content_set_id: &str, + exec: E, +) -> crate::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + let row = sqlx::query_as::<_, ContentSetSyncStateRow>( + " + SELECT * + FROM instance_content_set_sync_state + WHERE content_set_id = ? + ", + ) + .bind(content_set_id) + .fetch_optional(exec) + .await?; + + row.map(TryInto::try_into).transpose() +} + +pub(crate) async fn get_instance_files<'e, E>( + instance_id: &str, + exec: E, +) -> crate::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + let rows = sqlx::query_as::<_, InstanceFileRow>( + " + SELECT * + FROM instance_files + WHERE instance_id = ? + ORDER BY relative_path ASC + ", + ) + .bind(instance_id) + .fetch_all(exec) + .await?; + + rows.into_iter().map(TryInto::try_into).collect() +} + +pub(crate) async fn get_content_entries<'e, E>( + content_set_id: &str, + exec: E, +) -> crate::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + let rows = sqlx::query_as::<_, ContentEntryRow>( + " + SELECT * + FROM instance_content_entries + WHERE content_set_id = ? + ORDER BY added_at ASC, id ASC + ", + ) + .bind(content_set_id) + .fetch_all(exec) + .await?; + + rows.into_iter().map(TryInto::try_into).collect() +} + +pub(crate) async fn get_content_update_check<'e, E>( + content_entry_id: &str, + exec: E, +) -> crate::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + let row = sqlx::query_as::<_, ContentUpdateCheckRow>( + " + SELECT * + FROM instance_content_update_checks + WHERE content_entry_id = ? + ", + ) + .bind(content_entry_id) + .fetch_optional(exec) + .await?; + + Ok(row.map(Into::into)) +} + +fn project_type_from_str(value: &str) -> crate::Result { + match value { + "mod" => Ok(ProjectType::Mod), + "datapack" => Ok(ProjectType::DataPack), + "resourcepack" => Ok(ProjectType::ResourcePack), + "shader" | "shaderpack" => Ok(ProjectType::ShaderPack), + other => Err(crate::ErrorKind::InputError(format!( + "Unknown content project type {other}" + )) + .into()), + } +} + +fn timestamp(value: i64) -> DateTime { + Utc.timestamp_opt(value, 0) + .single() + .unwrap_or_else(Utc::now) +} + +fn optional_timestamp(value: i64) -> Option> { + Utc.timestamp_opt(value, 0).single() +} + +fn unsigned(value: i64, column: &str) -> crate::Result { + if value < 0 { + return Err(crate::ErrorKind::InputError(format!( + "Expected {column} to be non-negative" + )) + .into()); + } + + Ok(value as u64) +} diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs new file mode 100644 index 0000000000..67a26fb5c7 --- /dev/null +++ b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs @@ -0,0 +1,419 @@ +#![allow(dead_code)] + +use crate::state::instances::{ + Instance, InstanceLaunchOverrides, InstanceLink, InstanceRef, +}; +use crate::state::{ + Hooks, LauncherFeatureVersion, MemorySettings, ProfileInstallStage, + ReleaseChannel, WindowSize, +}; +use chrono::{DateTime, TimeZone, Utc}; +use serde::de::DeserializeOwned; +use sqlx::{Executor, Sqlite}; +use uuid::Uuid; + +#[derive(Debug, sqlx::FromRow)] +pub(crate) struct InstanceRow { + pub id: String, + pub path: String, + pub applied_content_set_id: Option, + pub install_stage: String, + pub launcher_feature_version: String, + pub update_channel: String, + pub name: String, + pub icon_path: Option, + pub created: i64, + pub modified: i64, + pub last_played: Option, + pub submitted_time_played: i64, + pub recent_time_played: i64, +} + +impl TryFrom for Instance { + type Error = crate::Error; + + fn try_from(row: InstanceRow) -> crate::Result { + Ok(Self { + id: row.id, + path: row.path, + applied_content_set_id: row.applied_content_set_id, + install_stage: ProfileInstallStage::from_str(&row.install_stage), + launcher_feature_version: LauncherFeatureVersion::from_str( + &row.launcher_feature_version, + ), + update_channel: ReleaseChannel::from_key(&row.update_channel), + name: row.name, + icon_path: row.icon_path, + created: timestamp(row.created), + modified: timestamp(row.modified), + last_played: row.last_played.and_then(optional_timestamp), + submitted_time_played: unsigned( + row.submitted_time_played, + "submitted_time_played", + )?, + recent_time_played: unsigned( + row.recent_time_played, + "recent_time_played", + )?, + }) + } +} + +#[derive(Debug, sqlx::FromRow)] +pub(crate) struct InstanceLinkRow { + pub instance_id: String, + pub link_kind: String, + pub modrinth_project_id: Option, + pub modrinth_version_id: Option, + pub server_project_id: Option, + pub content_project_id: Option, + pub content_version_id: Option, + pub hosting_server_id: Option, + pub hosting_instance_ids: Option, + pub hosting_active_instance_id: Option, + pub shared_instance_id: Option, +} + +impl TryFrom for InstanceLink { + type Error = crate::Error; + + fn try_from(row: InstanceLinkRow) -> crate::Result { + match row.link_kind.as_str() { + "unmanaged" => Ok(Self::Unmanaged), + "modrinth_modpack" => Ok(Self::ModrinthModpack { + project_id: required( + row.modrinth_project_id, + "modrinth_project_id", + )?, + version_id: required( + row.modrinth_version_id, + "modrinth_version_id", + )?, + }), + "server_project" => Ok(Self::ServerProject { + project_id: required( + row.server_project_id, + "server_project_id", + )?, + }), + "server_project_modpack" => Ok(Self::ServerProjectModpack { + server_project_id: required( + row.server_project_id, + "server_project_id", + )?, + content_project_id: required( + row.content_project_id, + "content_project_id", + )?, + content_version_id: required( + row.content_version_id, + "content_version_id", + )?, + }), + "modrinth_hosting" => Ok(Self::ModrinthHosting { + server_id: parse_uuid( + row.hosting_server_id, + "hosting_server_id", + )?, + instance_ids: parse_optional_json( + row.hosting_instance_ids, + "hosting_instance_ids", + )? + .unwrap_or_default(), + active_instance_id: parse_optional_uuid( + row.hosting_active_instance_id, + "hosting_active_instance_id", + )?, + }), + "imported_modpack" => Ok(Self::ImportedModpack { + project_id: row.modrinth_project_id, + version_id: row.modrinth_version_id, + }), + "shared_instance" => Ok(Self::SharedInstance { + shared_instance_id: parse_uuid( + row.shared_instance_id, + "shared_instance_id", + )?, + }), + other => Err(crate::ErrorKind::InputError(format!( + "Unknown instance link kind {other}" + )) + .into()), + } + } +} + +#[derive(Debug, sqlx::FromRow)] +pub(crate) struct InstanceLaunchOverridesRow { + pub instance_id: String, + pub java_path: Option, + pub extra_launch_args: Option, + pub custom_env_vars: Option, + pub memory: Option, + pub force_fullscreen: Option, + pub game_resolution_x: Option, + pub game_resolution_y: Option, + pub hook_pre_launch: Option, + pub hook_wrapper: Option, + pub hook_post_exit: Option, +} + +impl TryFrom for InstanceLaunchOverrides { + type Error = crate::Error; + + fn try_from(row: InstanceLaunchOverridesRow) -> crate::Result { + Ok(Self { + instance_id: row.instance_id, + java_path: row.java_path, + extra_launch_args: parse_optional_json( + row.extra_launch_args, + "extra_launch_args", + )?, + custom_env_vars: parse_optional_json( + row.custom_env_vars, + "custom_env_vars", + )?, + memory: match row.memory { + Some(maximum) => Some(MemorySettings { + maximum: unsigned(maximum, "memory")? as u32, + }), + None => None, + }, + force_fullscreen: row.force_fullscreen.map(|value| value == 1), + game_resolution: match ( + row.game_resolution_x, + row.game_resolution_y, + ) { + (Some(x), Some(y)) => Some(WindowSize( + unsigned(x, "game_resolution_x")? as u16, + unsigned(y, "game_resolution_y")? as u16, + )), + _ => None, + }, + hooks: Hooks { + pre_launch: row.hook_pre_launch, + wrapper: row.hook_wrapper, + post_exit: row.hook_post_exit, + }, + }) + } +} + +pub(crate) async fn get_instance_by_id<'e, E>( + id: &str, + exec: E, +) -> crate::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + let row = sqlx::query_as::<_, InstanceRow>( + " + SELECT * + FROM instances + WHERE id = ? + ", + ) + .bind(id) + .fetch_optional(exec) + .await?; + + row.map(TryInto::try_into).transpose() +} + +pub(crate) async fn get_instance_by_path<'e, E>( + path: &str, + exec: E, +) -> crate::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + let row = sqlx::query_as::<_, InstanceRow>( + " + SELECT * + FROM instances + WHERE path = ? + ", + ) + .bind(path) + .fetch_optional(exec) + .await?; + + row.map(TryInto::try_into).transpose() +} + +pub(crate) async fn resolve_instance<'e, E>( + instance: InstanceRef<'_>, + exec: E, +) -> crate::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + match instance { + InstanceRef::Id(id) => get_instance_by_id(id, exec).await, + InstanceRef::Path(path) => get_instance_by_path(path, exec).await, + } +} + +pub(crate) async fn get_instance_link<'e, E>( + instance_id: &str, + exec: E, +) -> crate::Result +where + E: Executor<'e, Database = Sqlite>, +{ + let row = sqlx::query_as::<_, InstanceLinkRow>( + " + SELECT + instance_id, + link_kind, + modrinth_project_id, + modrinth_version_id, + server_project_id, + content_project_id, + content_version_id, + hosting_server_id, + json(hosting_instance_ids) AS hosting_instance_ids, + hosting_active_instance_id, + shared_instance_id + FROM instance_links + WHERE instance_id = ? + ", + ) + .bind(instance_id) + .fetch_optional(exec) + .await?; + + match row { + Some(row) => row.try_into(), + None => Ok(InstanceLink::Unmanaged), + } +} + +pub(crate) async fn get_instance_groups<'e, E>( + instance_id: &str, + exec: E, +) -> crate::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + let rows = sqlx::query_scalar::<_, String>( + " + SELECT group_name + FROM instance_groups + WHERE instance_id = ? + ORDER BY group_name + ", + ) + .bind(instance_id) + .fetch_all(exec) + .await?; + + Ok(rows) +} + +pub(crate) async fn get_instance_launch_overrides<'e, E>( + instance_id: &str, + exec: E, +) -> crate::Result> +where + E: Executor<'e, Database = Sqlite>, +{ + let row = sqlx::query_as::<_, InstanceLaunchOverridesRow>( + " + SELECT + instance_id, + java_path, + json(extra_launch_args) AS extra_launch_args, + json(custom_env_vars) AS custom_env_vars, + memory, + force_fullscreen, + game_resolution_x, + game_resolution_y, + hook_pre_launch, + hook_wrapper, + hook_post_exit + FROM instance_launch_overrides + WHERE instance_id = ? + ", + ) + .bind(instance_id) + .fetch_optional(exec) + .await?; + + row.map(TryInto::try_into).transpose() +} + +fn required(value: Option, column: &str) -> crate::Result { + value.ok_or_else(|| { + crate::ErrorKind::InputError(format!( + "Missing required instance link column {column}" + )) + .into() + }) +} + +fn parse_uuid(value: Option, column: &str) -> crate::Result { + let value = required(value, column)?; + + value.parse().map_err(|err| { + crate::ErrorKind::InputError(format!("Invalid {column}: {err}")).into() + }) +} + +fn parse_optional_uuid( + value: Option, + column: &str, +) -> crate::Result> { + value + .map(|value| { + value.parse().map_err(|err| { + crate::ErrorKind::InputError(format!("Invalid {column}: {err}")) + .into() + }) + }) + .transpose() +} + +fn parse_optional_json( + value: Option, + column: &str, +) -> crate::Result> +where + T: DeserializeOwned, +{ + let Some(value) = value else { + return Ok(None); + }; + + if value == "null" { + return Ok(None); + } + + serde_json::from_str(&value).map(Some).map_err(|err| { + crate::ErrorKind::InputError(format!( + "Invalid launch override JSON in {column}: {err}" + )) + .into() + }) +} + +fn timestamp(value: i64) -> DateTime { + Utc.timestamp_opt(value, 0) + .single() + .unwrap_or_else(Utc::now) +} + +fn optional_timestamp(value: i64) -> Option> { + Utc.timestamp_opt(value, 0).single() +} + +fn unsigned(value: i64, column: &str) -> crate::Result { + if value < 0 { + return Err(crate::ErrorKind::InputError(format!( + "Expected {column} to be non-negative" + )) + .into()); + } + + Ok(value as u64) +} diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/mod.rs b/packages/app-lib/src/state/instances/adapters/sqlite/mod.rs new file mode 100644 index 0000000000..d8b478f4da --- /dev/null +++ b/packages/app-lib/src/state/instances/adapters/sqlite/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod content_rows; +pub(crate) mod instance_rows; diff --git a/packages/app-lib/src/state/instances/commands/mod.rs b/packages/app-lib/src/state/instances/commands/mod.rs new file mode 100644 index 0000000000..70b786d12e --- /dev/null +++ b/packages/app-lib/src/state/instances/commands/mod.rs @@ -0,0 +1 @@ +// TODO diff --git a/packages/app-lib/src/state/instances/content.rs b/packages/app-lib/src/state/instances/content.rs index 0e477c6960..c16c0d9c4c 100644 --- a/packages/app-lib/src/state/instances/content.rs +++ b/packages/app-lib/src/state/instances/content.rs @@ -17,6 +17,8 @@ //! immutable), so re-download is only needed if cache was cleared or //! profile predates this caching mechanism. +// TODO: migrate to new system + use crate::pack::install_from::{PackFileHash, PackFormat}; use crate::state::profiles::{Profile, ProfileFile, ProjectType}; use crate::state::{CacheBehaviour, CachedEntry, ReleaseChannel}; diff --git a/packages/app-lib/src/state/instances/domain/mod.rs b/packages/app-lib/src/state/instances/domain/mod.rs new file mode 100644 index 0000000000..70b786d12e --- /dev/null +++ b/packages/app-lib/src/state/instances/domain/mod.rs @@ -0,0 +1 @@ +// TODO diff --git a/packages/app-lib/src/state/instances/ids.rs b/packages/app-lib/src/state/instances/ids.rs new file mode 100644 index 0000000000..bc2c47b5fa --- /dev/null +++ b/packages/app-lib/src/state/instances/ids.rs @@ -0,0 +1,21 @@ +#![allow(dead_code)] + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct InstanceId(pub String); + +#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct ContentSetId(pub String); + +#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct ContentEntryId(pub String); + +#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct InstanceFileId(pub String); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InstanceRef<'a> { + Id(&'a str), + Path(&'a str), +} diff --git a/packages/app-lib/src/state/instances/legacy/mod.rs b/packages/app-lib/src/state/instances/legacy/mod.rs new file mode 100644 index 0000000000..21bb3967a0 --- /dev/null +++ b/packages/app-lib/src/state/instances/legacy/mod.rs @@ -0,0 +1 @@ +//! TODO: folder used to migrate profiles to v2 diff --git a/packages/app-lib/src/state/instances/mod.rs b/packages/app-lib/src/state/instances/mod.rs index 931e32a6c1..51fe89cbaa 100644 --- a/packages/app-lib/src/state/instances/mod.rs +++ b/packages/app-lib/src/state/instances/mod.rs @@ -1,4 +1,13 @@ -//! Instance-related modules for profile/instance management. - mod content; pub use self::content::*; + +mod ids; +pub use self::ids::*; + +mod model; +pub use self::model::*; + +mod adapters; +mod commands; +mod domain; +mod legacy; diff --git a/packages/app-lib/src/state/instances/model/content_entry.rs b/packages/app-lib/src/state/instances/model/content_entry.rs new file mode 100644 index 0000000000..439cd86808 --- /dev/null +++ b/packages/app-lib/src/state/instances/model/content_entry.rs @@ -0,0 +1,52 @@ +use crate::state::ProjectType; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use super::{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 { + 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, + pub project_type: ProjectType, + pub project_id: Option, + pub version_id: Option, + pub source_kind: ContentSourceKind, + pub server_requirement: ContentRequirement, + pub client_requirement: ContentRequirement, + pub enabled: bool, + pub added_at: DateTime, + pub modified_at: DateTime, +} diff --git a/packages/app-lib/src/state/instances/model/content_set.rs b/packages/app-lib/src/state/instances/model/content_set.rs new file mode 100644 index 0000000000..7fb62ea099 --- /dev/null +++ b/packages/app-lib/src/state/instances/model/content_set.rs @@ -0,0 +1,87 @@ +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, + ModrinthModpack, + ServerProject, + ModrinthHosting, + ImportedModpack, + SharedInstance, +} + +impl ContentSourceKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Local => "local", + Self::ModrinthModpack => "modrinth_modpack", + Self::ServerProject => "server_project", + Self::ModrinthHosting => "modrinth_hosting", + Self::ImportedModpack => "imported_modpack", + Self::SharedInstance => "shared_instance", + } + } + + pub fn from_str(value: &str) -> crate::Result { + match value { + "local" => Ok(Self::Local), + "modrinth_modpack" => Ok(Self::ModrinthModpack), + "server_project" => Ok(Self::ServerProject), + "modrinth_hosting" => Ok(Self::ModrinthHosting), + "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 { + 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, + pub loader: ModLoader, + pub loader_version: Option, + pub created: DateTime, + pub modified: DateTime, +} diff --git a/packages/app-lib/src/state/instances/model/content_set_remote_ref.rs b/packages/app-lib/src/state/instances/model/content_set_remote_ref.rs new file mode 100644 index 0000000000..09e5d34a6a --- /dev/null +++ b/packages/app-lib/src/state/instances/model/content_set_remote_ref.rs @@ -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 { + 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, +} diff --git a/packages/app-lib/src/state/instances/model/content_set_sync_state.rs b/packages/app-lib/src/state/instances/model/content_set_sync_state.rs new file mode 100644 index 0000000000..9dcdbd91c3 --- /dev/null +++ b/packages/app-lib/src/state/instances/model/content_set_sync_state.rs @@ -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 { + 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 { + 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, + pub latest_available_update_id: Option, + pub checked_at: Option>, + pub status: ContentSetSyncStatus, +} diff --git a/packages/app-lib/src/state/instances/model/file.rs b/packages/app-lib/src/state/instances/model/file.rs new file mode 100644 index 0000000000..8a45adcc34 --- /dev/null +++ b/packages/app-lib/src/state/instances/model/file.rs @@ -0,0 +1,16 @@ +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, + pub modified_at: DateTime, +} diff --git a/packages/app-lib/src/state/instances/model/instance.rs b/packages/app-lib/src/state/instances/model/instance.rs new file mode 100644 index 0000000000..ad6f552ab1 --- /dev/null +++ b/packages/app-lib/src/state/instances/model/instance.rs @@ -0,0 +1,22 @@ +use crate::state::{ + LauncherFeatureVersion, ProfileInstallStage, 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, + pub install_stage: ProfileInstallStage, + pub launcher_feature_version: LauncherFeatureVersion, + pub update_channel: ReleaseChannel, + pub name: String, + pub icon_path: Option, + pub created: DateTime, + pub modified: DateTime, + pub last_played: Option>, + pub submitted_time_played: u64, + pub recent_time_played: u64, +} diff --git a/packages/app-lib/src/state/instances/model/launch.rs b/packages/app-lib/src/state/instances/model/launch.rs new file mode 100644 index 0000000000..3291120c3b --- /dev/null +++ b/packages/app-lib/src/state/instances/model/launch.rs @@ -0,0 +1,14 @@ +use crate::state::{Hooks, MemorySettings, WindowSize}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct InstanceLaunchOverrides { + pub instance_id: String, + pub java_path: Option, + pub extra_launch_args: Option>, + pub custom_env_vars: Option>, + pub memory: Option, + pub force_fullscreen: Option, + pub game_resolution: Option, + pub hooks: Hooks, +} diff --git a/packages/app-lib/src/state/instances/model/link.rs b/packages/app-lib/src/state/instances/model/link.rs new file mode 100644 index 0000000000..752147436a --- /dev/null +++ b/packages/app-lib/src/state/instances/model/link.rs @@ -0,0 +1,35 @@ +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, + }, + 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, + }, + /// Hosting sync still flows through the shared-instance service. + ModrinthHosting { + server_id: Uuid, + instance_ids: Vec, + active_instance_id: Option, + }, + /// A custom modpack source without a Modrinth project/version link. + ImportedModpack { + project_id: Option, + version_id: Option, + }, + SharedInstance { + shared_instance_id: Uuid, + }, +} diff --git a/packages/app-lib/src/state/instances/model/manifest.rs b/packages/app-lib/src/state/instances/model/manifest.rs new file mode 100644 index 0000000000..e44f71ade1 --- /dev/null +++ b/packages/app-lib/src/state/instances/model/manifest.rs @@ -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, + pub files: Vec, +} diff --git a/packages/app-lib/src/state/instances/model/mod.rs b/packages/app-lib/src/state/instances/model/mod.rs new file mode 100644 index 0000000000..cacd063aff --- /dev/null +++ b/packages/app-lib/src/state/instances/model/mod.rs @@ -0,0 +1,34 @@ +#![allow(dead_code)] + +mod content_entry; +pub use self::content_entry::*; + +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 file; +pub use self::file::*; + +mod instance; +pub use self::instance::*; + +mod launch; +pub use self::launch::*; + +mod link; +pub use self::link::*; + +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() +} diff --git a/packages/app-lib/src/state/instances/model/update_check.rs b/packages/app-lib/src/state/instances/model/update_check.rs new file mode 100644 index 0000000000..142843c9d4 --- /dev/null +++ b/packages/app-lib/src/state/instances/model/update_check.rs @@ -0,0 +1,11 @@ +use crate::state::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 update_version_id: Option, + pub checked_at: DateTime, +} From b21fa782cc99bec224f7844b07c8596489ed5d49 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Thu, 18 Jun 2026 16:46:20 +0100 Subject: [PATCH 2/9] feat: use old profiles with compat layer --- packages/app-lib/src/api/profile/create.rs | 2 +- packages/app-lib/src/api/profile/mod.rs | 14 +- packages/app-lib/src/api/worlds.rs | 5 +- .../adapters/sqlite/instance_rows.rs | 325 +++++++++++++++++- packages/app-lib/src/state/instances/ids.rs | 8 + .../app-lib/src/state/instances/legacy/mod.rs | 3 +- .../instances/legacy/profile_projection.rs | 178 ++++++++++ packages/app-lib/src/state/instances/mod.rs | 2 +- packages/app-lib/src/state/mod.rs | 4 + packages/app-lib/src/state/profiles.rs | 26 +- 10 files changed, 550 insertions(+), 17 deletions(-) create mode 100644 packages/app-lib/src/state/instances/legacy/profile_projection.rs diff --git a/packages/app-lib/src/api/profile/create.rs b/packages/app-lib/src/api/profile/create.rs index c1a1491c1b..366906c126 100644 --- a/packages/app-lib/src/api/profile/create.rs +++ b/packages/app-lib/src/api/profile/create.rs @@ -143,7 +143,7 @@ pub async fn profile_create( ) .await; - profile.upsert(&state.pool).await?; + profile.upsert_with_instance_metadata(&state.pool).await?; emit_profile(&profile.path, ProfilePayloadType::Created).await?; diff --git a/packages/app-lib/src/api/profile/mod.rs b/packages/app-lib/src/api/profile/mod.rs index a7f2f113ac..f34253a9da 100644 --- a/packages/app-lib/src/api/profile/mod.rs +++ b/packages/app-lib/src/api/profile/mod.rs @@ -63,7 +63,9 @@ pub async fn remove(path: &str) -> crate::Result<()> { #[tracing::instrument] pub async fn get(path: &str) -> crate::Result> { let state = State::get().await?; - let profile = Profile::get(path, &state.pool).await?; + let profile = + crate::state::get_profile_projection_by_path(path, &state.pool) + .await?; Ok(profile) } @@ -71,7 +73,9 @@ pub async fn get(path: &str) -> crate::Result> { #[tracing::instrument] pub async fn get_many(paths: &[&str]) -> crate::Result> { let state = State::get().await?; - let profiles = Profile::get_many(paths, &state.pool).await?; + let profiles = + crate::state::get_profile_projections_by_paths(paths, &state.pool) + .await?; Ok(profiles) } @@ -240,7 +244,7 @@ where if let Some(mut profile) = get(path).await? { action(&mut profile).await?; - profile.upsert(&state.pool).await?; + profile.upsert_with_instance_metadata(&state.pool).await?; emit_profile(path, ProfilePayloadType::Edited).await?; @@ -274,7 +278,7 @@ pub async fn edit_icon( profile.icon_path = None; } - profile.upsert(&state.pool).await?; + profile.upsert_with_instance_metadata(&state.pool).await?; emit_profile(path, ProfilePayloadType::Edited).await?; @@ -336,7 +340,7 @@ pub async fn get_optimal_jre_key( #[tracing::instrument] pub async fn list() -> crate::Result> { let state = State::get().await?; - let profiles = Profile::get_all(&state.pool).await?; + let profiles = crate::state::list_profile_projections(&state.pool).await?; Ok(profiles) } diff --git a/packages/app-lib/src/api/worlds.rs b/packages/app-lib/src/api/worlds.rs index 3801424646..dbcc5ba61d 100644 --- a/packages/app-lib/src/api/worlds.rs +++ b/packages/app-lib/src/api/worlds.rs @@ -195,7 +195,8 @@ pub async fn get_recent_worlds( let state = State::get().await?; let profiles_dir = state.directories.profiles_dir(); - let mut profiles = Profile::get_all(&state.pool).await?; + let mut profiles = crate::state::list_profile_projections(&state.pool) + .await?; profiles.sort_by_key(|x| Reverse(x.last_played)); let mut result = Vec::with_capacity(limit); @@ -921,7 +922,7 @@ pub async fn get_profile_protocol_version( let version = launcher::read_protocol_version_from_jar(client_path).await?; if version.is_some() { profile.protocol_version = version; - profile.upsert(&state.pool).await?; + profile.upsert_with_instance_metadata(&state.pool).await?; } Ok(version.map(ProtocolVersion::modern)) } diff --git a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs index 67a26fb5c7..11888abd44 100644 --- a/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs +++ b/packages/app-lib/src/state/instances/adapters/sqlite/instance_rows.rs @@ -1,15 +1,17 @@ #![allow(dead_code)] use crate::state::instances::{ - Instance, InstanceLaunchOverrides, InstanceLink, InstanceRef, + ContentSetStatus, ContentSourceKind, Instance, InstanceLaunchOverrides, + InstanceLink, InstanceRef, legacy_default_content_set_id, + legacy_instance_id, }; use crate::state::{ - Hooks, LauncherFeatureVersion, MemorySettings, ProfileInstallStage, - ReleaseChannel, WindowSize, + Hooks, LauncherFeatureVersion, MemorySettings, Profile, + ProfileInstallStage, ReleaseChannel, WindowSize, }; use chrono::{DateTime, TimeZone, Utc}; use serde::de::DeserializeOwned; -use sqlx::{Executor, Sqlite}; +use sqlx::{Executor, Sqlite, SqlitePool}; use uuid::Uuid; #[derive(Debug, sqlx::FromRow)] @@ -241,6 +243,21 @@ where row.map(TryInto::try_into).transpose() } +pub(crate) async fn list_instances( + pool: &SqlitePool, +) -> crate::Result> { + let rows = sqlx::query_as::<_, InstanceRow>( + " + SELECT * + FROM instances + ", + ) + .fetch_all(pool) + .await?; + + rows.into_iter().map(TryInto::try_into).collect() +} + pub(crate) async fn resolve_instance<'e, E>( instance: InstanceRef<'_>, exec: E, @@ -343,6 +360,271 @@ where row.map(TryInto::try_into).transpose() } +pub(crate) async fn upsert_instance_from_profile( + profile: &Profile, + pool: &SqlitePool, +) -> crate::Result<()> { + let mut tx = pool.begin().await?; + + let instance_id = legacy_instance_id(&profile.path); + let content_set_id = legacy_default_content_set_id(&profile.path); + let icon_path = profile.icon_path.as_deref(); + let loader_version = profile.loader_version.as_deref(); + + let install_stage = profile.install_stage.as_str(); + let launcher_feature_version = profile.launcher_feature_version.as_str(); + let update_channel = profile.preferred_update_channel.key(); + let created = profile.created.timestamp(); + let modified = profile.modified.timestamp(); + let last_played = profile.last_played.map(|value| value.timestamp()); + let submitted_time_played = profile.submitted_time_played as i64; + let recent_time_played = profile.recent_time_played as i64; + + sqlx::query( + " + INSERT INTO instances ( + id, + path, + applied_content_set_id, + install_stage, + launcher_feature_version, + update_channel, + name, + icon_path, + created, + modified, + last_played, + submitted_time_played, + recent_time_played + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET + path = excluded.path, + applied_content_set_id = excluded.applied_content_set_id, + install_stage = excluded.install_stage, + launcher_feature_version = excluded.launcher_feature_version, + update_channel = excluded.update_channel, + name = excluded.name, + icon_path = excluded.icon_path, + created = excluded.created, + modified = excluded.modified, + last_played = excluded.last_played, + submitted_time_played = excluded.submitted_time_played, + recent_time_played = excluded.recent_time_played + ", + ) + .bind(&instance_id) + .bind(profile.path.as_str()) + .bind(&content_set_id) + .bind(install_stage) + .bind(launcher_feature_version) + .bind(update_channel) + .bind(profile.name.as_str()) + .bind(icon_path) + .bind(created) + .bind(modified) + .bind(last_played) + .bind(submitted_time_played) + .bind(recent_time_played) + .execute(&mut *tx) + .await?; + + let source_kind = profile_content_source_kind(profile).as_str(); + let content_set_status = ContentSetStatus::Available.as_str(); + let loader = profile.loader.as_str(); + let protocol_version = profile.protocol_version.map(|value| value as i64); + + sqlx::query( + " + INSERT INTO instance_content_sets ( + id, + instance_id, + name, + source_kind, + status, + game_version, + protocol_version, + loader, + loader_version, + created, + modified + ) + VALUES (?, ?, 'Default', ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET + instance_id = excluded.instance_id, + source_kind = excluded.source_kind, + status = excluded.status, + game_version = excluded.game_version, + protocol_version = excluded.protocol_version, + loader = excluded.loader, + loader_version = excluded.loader_version, + modified = excluded.modified + ", + ) + .bind(&content_set_id) + .bind(&instance_id) + .bind(source_kind) + .bind(content_set_status) + .bind(profile.game_version.as_str()) + .bind(protocol_version) + .bind(loader) + .bind(loader_version) + .bind(created) + .bind(modified) + .execute(&mut *tx) + .await?; + + let ( + link_kind, + modrinth_project_id, + modrinth_version_id, + server_project_id, + ) = profile_link_columns(profile); + + sqlx::query( + " + INSERT INTO instance_links ( + instance_id, + link_kind, + modrinth_project_id, + modrinth_version_id, + server_project_id, + content_project_id, + content_version_id, + hosting_server_id, + hosting_instance_ids, + hosting_active_instance_id, + shared_instance_id + ) + VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL) + ON CONFLICT (instance_id) DO UPDATE SET + link_kind = excluded.link_kind, + modrinth_project_id = excluded.modrinth_project_id, + modrinth_version_id = excluded.modrinth_version_id, + server_project_id = excluded.server_project_id, + content_project_id = excluded.content_project_id, + content_version_id = excluded.content_version_id, + hosting_server_id = excluded.hosting_server_id, + hosting_instance_ids = excluded.hosting_instance_ids, + hosting_active_instance_id = excluded.hosting_active_instance_id, + shared_instance_id = excluded.shared_instance_id + ", + ) + .bind(&instance_id) + .bind(link_kind) + .bind(modrinth_project_id) + .bind(modrinth_version_id) + .bind(server_project_id) + .execute(&mut *tx) + .await?; + + sqlx::query( + " + DELETE FROM instance_groups + WHERE instance_id = ? + ", + ) + .bind(&instance_id) + .execute(&mut *tx) + .await?; + + for group in &profile.groups { + sqlx::query( + " + INSERT OR IGNORE INTO instance_groups (instance_id, group_name) + VALUES (?, ?) + ", + ) + .bind(&instance_id) + .bind(group.as_str()) + .execute(&mut *tx) + .await?; + } + + let extra_launch_args = profile + .extra_launch_args + .as_ref() + .map(serde_json::to_string) + .transpose()?; + let custom_env_vars = profile + .custom_env_vars + .as_ref() + .map(serde_json::to_string) + .transpose()?; + let memory = profile.memory.map(|value| value.maximum as i64); + let force_fullscreen = profile + .force_fullscreen + .map(|value| if value { 1_i64 } else { 0_i64 }); + let game_resolution_x = + profile.game_resolution.map(|value| value.0 as i64); + let game_resolution_y = + profile.game_resolution.map(|value| value.1 as i64); + + sqlx::query( + " + INSERT INTO instance_launch_overrides ( + instance_id, + java_path, + extra_launch_args, + custom_env_vars, + memory, + force_fullscreen, + game_resolution_x, + game_resolution_y, + hook_pre_launch, + hook_wrapper, + hook_post_exit + ) + VALUES (?, ?, jsonb(?), jsonb(?), ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (instance_id) DO UPDATE SET + java_path = excluded.java_path, + extra_launch_args = excluded.extra_launch_args, + custom_env_vars = excluded.custom_env_vars, + memory = excluded.memory, + force_fullscreen = excluded.force_fullscreen, + game_resolution_x = excluded.game_resolution_x, + game_resolution_y = excluded.game_resolution_y, + hook_pre_launch = excluded.hook_pre_launch, + hook_wrapper = excluded.hook_wrapper, + hook_post_exit = excluded.hook_post_exit + ", + ) + .bind(&instance_id) + .bind(profile.java_path.as_deref()) + .bind(extra_launch_args) + .bind(custom_env_vars) + .bind(memory) + .bind(force_fullscreen) + .bind(game_resolution_x) + .bind(game_resolution_y) + .bind(profile.hooks.pre_launch.as_deref()) + .bind(profile.hooks.wrapper.as_deref()) + .bind(profile.hooks.post_exit.as_deref()) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) +} + +pub(crate) async fn delete_instance_by_path( + path: &str, + pool: &SqlitePool, +) -> crate::Result<()> { + sqlx::query( + " + DELETE FROM instances + WHERE path = ? + ", + ) + .bind(path) + .execute(pool) + .await?; + + Ok(()) +} + fn required(value: Option, column: &str) -> crate::Result { value.ok_or_else(|| { crate::ErrorKind::InputError(format!( @@ -417,3 +699,38 @@ fn unsigned(value: i64, column: &str) -> crate::Result { Ok(value as u64) } + +fn profile_content_source_kind(profile: &Profile) -> ContentSourceKind { + match &profile.linked_data { + Some(linked_data) if linked_data.version_id.is_empty() => { + ContentSourceKind::ServerProject + } + Some(_) => ContentSourceKind::ModrinthModpack, + None => ContentSourceKind::Local, + } +} + +fn profile_link_columns( + profile: &Profile, +) -> ( + &'static str, + Option, + Option, + Option, +) { + match &profile.linked_data { + Some(linked_data) if linked_data.version_id.is_empty() => ( + "server_project", + None, + None, + Some(linked_data.project_id.clone()), + ), + Some(linked_data) => ( + "modrinth_modpack", + Some(linked_data.project_id.clone()), + Some(linked_data.version_id.clone()), + None, + ), + None => ("unmanaged", None, None, None), + } +} diff --git a/packages/app-lib/src/state/instances/ids.rs b/packages/app-lib/src/state/instances/ids.rs index bc2c47b5fa..cfeaeec210 100644 --- a/packages/app-lib/src/state/instances/ids.rs +++ b/packages/app-lib/src/state/instances/ids.rs @@ -19,3 +19,11 @@ pub enum InstanceRef<'a> { Id(&'a str), Path(&'a str), } + +pub(crate) fn legacy_instance_id(path: &str) -> String { + format!("legacy:{path}") +} + +pub(crate) fn legacy_default_content_set_id(path: &str) -> String { + format!("{}:default", legacy_instance_id(path)) +} diff --git a/packages/app-lib/src/state/instances/legacy/mod.rs b/packages/app-lib/src/state/instances/legacy/mod.rs index 21bb3967a0..ba1e5b9442 100644 --- a/packages/app-lib/src/state/instances/legacy/mod.rs +++ b/packages/app-lib/src/state/instances/legacy/mod.rs @@ -1 +1,2 @@ -//! TODO: folder used to migrate profiles to v2 +mod profile_projection; +pub(crate) use self::profile_projection::*; diff --git a/packages/app-lib/src/state/instances/legacy/profile_projection.rs b/packages/app-lib/src/state/instances/legacy/profile_projection.rs new file mode 100644 index 0000000000..59aa2809d8 --- /dev/null +++ b/packages/app-lib/src/state/instances/legacy/profile_projection.rs @@ -0,0 +1,178 @@ +use crate::state::{ + ContentSet, Hooks, Instance, InstanceLaunchOverrides, InstanceLink, + LinkedData, Profile, +}; +use sqlx::SqlitePool; + +use super::super::adapters::sqlite::{content_rows, instance_rows}; + +pub(crate) async fn get_profile_projection_by_path( + path: &str, + pool: &SqlitePool, +) -> crate::Result> { + let Some(instance) = + instance_rows::get_instance_by_path(path, pool).await? + else { + return Ok(None); + }; + + project_profile(instance, pool).await.map(Some) +} + +pub(crate) async fn get_profile_projections_by_paths( + paths: &[&str], + pool: &SqlitePool, +) -> crate::Result> { + let mut profiles = Vec::with_capacity(paths.len()); + + for path in paths { + if let Some(profile) = + get_profile_projection_by_path(path, pool).await? + { + profiles.push(profile); + } + } + + Ok(profiles) +} + +pub(crate) async fn list_profile_projections( + pool: &SqlitePool, +) -> crate::Result> { + let instances = instance_rows::list_instances(pool).await?; + let mut profiles = Vec::with_capacity(instances.len()); + + for instance in instances { + profiles.push(project_profile(instance, pool).await?); + } + + Ok(profiles) +} + +pub(crate) async fn sync_profile_metadata( + profile: &Profile, + pool: &SqlitePool, +) -> crate::Result<()> { + instance_rows::upsert_instance_from_profile(profile, pool).await +} + +pub(crate) async fn delete_profile_metadata_by_path( + path: &str, + pool: &SqlitePool, +) -> crate::Result<()> { + instance_rows::delete_instance_by_path(path, pool).await +} + +async fn project_profile( + instance: Instance, + pool: &SqlitePool, +) -> crate::Result { + let 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.path + )) + .as_error() + })?; + let link = instance_rows::get_instance_link(&instance.id, pool).await?; + let groups = instance_rows::get_instance_groups(&instance.id, pool).await?; + let launch_overrides = + instance_rows::get_instance_launch_overrides(&instance.id, pool) + .await? + .unwrap_or_else(|| default_launch_overrides(&instance.id)); + + Ok(profile_from_parts(instance, content_set, link, groups, launch_overrides)) +} + +fn profile_from_parts( + instance: Instance, + content_set: ContentSet, + link: InstanceLink, + groups: Vec, + launch_overrides: InstanceLaunchOverrides, +) -> Profile { + Profile { + path: instance.path, + install_stage: instance.install_stage, + launcher_feature_version: instance.launcher_feature_version, + name: instance.name, + icon_path: instance.icon_path, + game_version: content_set.game_version, + protocol_version: content_set.protocol_version, + loader: content_set.loader, + loader_version: content_set.loader_version, + groups, + linked_data: linked_data_from_link(link), + preferred_update_channel: instance.update_channel, + created: instance.created, + modified: instance.modified, + last_played: instance.last_played, + submitted_time_played: instance.submitted_time_played, + recent_time_played: instance.recent_time_played, + java_path: launch_overrides.java_path, + extra_launch_args: launch_overrides.extra_launch_args, + custom_env_vars: launch_overrides.custom_env_vars, + memory: launch_overrides.memory, + force_fullscreen: launch_overrides.force_fullscreen, + game_resolution: launch_overrides.game_resolution, + hooks: launch_overrides.hooks, + } +} + +fn linked_data_from_link(link: InstanceLink) -> Option { + match link { + InstanceLink::Unmanaged => None, + InstanceLink::ModrinthModpack { + project_id, + version_id, + } => Some(LinkedData { + project_id, + version_id, + locked: true, + }), + InstanceLink::ServerProject { project_id } => Some(LinkedData { + project_id, + version_id: String::new(), + locked: false, + }), + InstanceLink::ServerProjectModpack { + server_project_id, + content_version_id, + .. + } => Some(LinkedData { + project_id: server_project_id, + version_id: content_version_id, + locked: false, + }), + InstanceLink::ImportedModpack { + project_id: Some(project_id), + version_id: Some(version_id), + } => Some(LinkedData { + project_id, + version_id, + locked: false, + }), + InstanceLink::ImportedModpack { .. } + | InstanceLink::ModrinthHosting { .. } + | InstanceLink::SharedInstance { .. } => None, + } +} + +fn default_launch_overrides(instance_id: &str) -> InstanceLaunchOverrides { + InstanceLaunchOverrides { + instance_id: instance_id.to_string(), + java_path: None, + extra_launch_args: None, + custom_env_vars: None, + memory: None, + force_fullscreen: None, + game_resolution: None, + hooks: Hooks { + pre_launch: None, + wrapper: None, + post_exit: None, + }, + } +} diff --git a/packages/app-lib/src/state/instances/mod.rs b/packages/app-lib/src/state/instances/mod.rs index 51fe89cbaa..430cc8d85d 100644 --- a/packages/app-lib/src/state/instances/mod.rs +++ b/packages/app-lib/src/state/instances/mod.rs @@ -10,4 +10,4 @@ pub use self::model::*; mod adapters; mod commands; mod domain; -mod legacy; +pub(crate) mod legacy; diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs index 2b8ddf73a6..41987525ca 100644 --- a/packages/app-lib/src/state/mod.rs +++ b/packages/app-lib/src/state/mod.rs @@ -16,6 +16,10 @@ pub use self::profiles::*; mod instances; pub use self::instances::*; +pub(crate) use self::instances::legacy::{ + get_profile_projection_by_path, get_profile_projections_by_paths, + list_profile_projections, +}; mod settings; pub use self::settings::*; diff --git a/packages/app-lib/src/state/profiles.rs b/packages/app-lib/src/state/profiles.rs index c30412af22..2941abb892 100644 --- a/packages/app-lib/src/state/profiles.rs +++ b/packages/app-lib/src/state/profiles.rs @@ -615,6 +615,16 @@ impl Profile { Ok(()) } + pub async fn upsert_with_instance_metadata( + &self, + pool: &SqlitePool, + ) -> crate::Result<()> { + self.upsert(pool).await?; + super::instances::legacy::sync_profile_metadata(self, pool).await?; + + Ok(()) + } + pub async fn remove( profile_path: &str, pool: &SqlitePool, @@ -629,6 +639,12 @@ impl Profile { .execute(pool) .await?; + super::instances::legacy::delete_profile_metadata_by_path( + profile_path, + pool, + ) + .await?; + if let Ok(path) = crate::api::profile::get_full_path(profile_path).await { io::remove_dir_all(&path).await?; @@ -698,12 +714,16 @@ impl Profile { if profile.install_stage == ProfileInstallStage::MinecraftInstalling { profile.install_stage = ProfileInstallStage::PackInstalled; - profile.upsert(&state.pool).await?; + profile + .upsert_with_instance_metadata(&state.pool) + .await?; } else if profile.install_stage == ProfileInstallStage::PackInstalling { profile.install_stage = ProfileInstallStage::NotInstalled; - profile.upsert(&state.pool).await?; + profile + .upsert_with_instance_metadata(&state.pool) + .await?; } if profile.launcher_feature_version @@ -729,7 +749,7 @@ impl Profile { tracing::error!("Failed to migrate instance '{}': {}", profile.path, err); return; } - if let Err(err) = profile.upsert(&state.pool).await { + if let Err(err) = profile.upsert_with_instance_metadata(&state.pool).await { tracing::error!("Failed to update instance '{}' migration state: {}", profile.path, err); return; } From 1de17a6452359a0e1ba95781e10128d373ac9d71 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Fri, 19 Jun 2026 15:21:27 +0100 Subject: [PATCH 3/9] prototype: instances v2 --- apps/app-frontend/src/App.vue | 8 +- .../src/components/GridDisplay.vue | 24 +- .../src/components/RowDisplay.vue | 34 +- .../src/components/ui/AddContentButton.vue | 6 +- .../src/components/ui/AppActionBar.vue | 66 +- .../src/components/ui/ContextMenu.vue | 8 +- .../src/components/ui/ErrorModal.vue | 6 +- .../src/components/ui/ExportModal.vue | 8 +- .../src/components/ui/Instance.vue | 26 +- .../src/components/ui/InstanceIndicator.vue | 2 +- .../src/components/ui/ModpackVersionModal.vue | 8 +- .../components/ui/QuickInstanceSwitcher.vue | 18 +- .../install_flow/AddServerToInstanceModal.vue | 56 +- .../ui/instance_settings/GeneralSettings.vue | 42 +- .../ui/instance_settings/HooksSettings.vue | 12 +- .../InstallationSettings.vue | 71 +- .../ui/instance_settings/JavaSettings.vue | 8 +- .../ui/instance_settings/WindowSettings.vue | 6 +- .../ui/modal/InstanceModalTitlePrefix.vue | 2 +- .../ui/modal/InstanceSettingsModal.vue | 14 +- .../components/ui/modal/UpdateToPlayModal.vue | 31 +- .../src/components/ui/world/InstanceItem.vue | 30 +- .../components/ui/world/RecentWorldsList.vue | 62 +- .../ui/world/modal/AddServerModal.vue | 10 +- .../ui/world/modal/EditServerModal.vue | 8 +- .../modal/EditSingleplayerWorldModal.vue | 6 +- .../browse/use-app-server-browse.ts | 22 +- .../src/composables/useInstanceConsole.ts | 52 +- apps/app-frontend/src/helpers/auth.js | 2 +- apps/app-frontend/src/helpers/events.js | 19 +- apps/app-frontend/src/helpers/friends.ts | 2 +- apps/app-frontend/src/helpers/import.js | 9 +- .../src/helpers/instance-content.ts | 2 +- apps/app-frontend/src/helpers/instance.ts | 314 +++ apps/app-frontend/src/helpers/jre.js | 2 +- apps/app-frontend/src/helpers/logs.js | 50 +- apps/app-frontend/src/helpers/mr_auth.ts | 2 +- apps/app-frontend/src/helpers/pack.ts | 65 +- apps/app-frontend/src/helpers/process.js | 10 +- apps/app-frontend/src/helpers/profile.ts | 317 --- apps/app-frontend/src/helpers/settings.ts | 2 +- apps/app-frontend/src/helpers/state.ts | 4 +- apps/app-frontend/src/helpers/tags.js | 2 +- apps/app-frontend/src/helpers/types.d.ts | 56 +- apps/app-frontend/src/helpers/utils.js | 12 +- apps/app-frontend/src/helpers/worlds.ts | 49 +- apps/app-frontend/src/pages/Browse.vue | 40 +- apps/app-frontend/src/pages/Index.vue | 14 +- .../app-frontend/src/pages/instance/Files.vue | 34 +- .../app-frontend/src/pages/instance/Index.vue | 65 +- apps/app-frontend/src/pages/instance/Logs.vue | 24 +- apps/app-frontend/src/pages/instance/Mods.vue | 129 +- .../src/pages/instance/Worlds.vue | 64 +- .../app-frontend/src/pages/library/Custom.vue | 2 +- .../src/pages/library/Downloaded.vue | 2 +- apps/app-frontend/src/pages/library/Index.vue | 8 +- .../src/pages/library/Modpacks.vue | 6 +- .../src/pages/library/Servers.vue | 6 +- apps/app-frontend/src/pages/project/Index.vue | 16 +- .../src/providers/content-install.ts | 114 +- .../src/providers/server-install.ts | 83 +- .../src/providers/setup/creation-modal.ts | 14 +- apps/app-frontend/src/store/install.js | 24 +- apps/app/build.rs | 87 +- apps/app/capabilities/plugins.json | 3 +- apps/app/src/api/files.rs | 2 +- apps/app/src/api/import.rs | 5 +- apps/app/src/api/instance.rs | 708 +++++++ apps/app/src/api/logs.rs | 46 +- apps/app/src/api/mod.rs | 3 +- apps/app/src/api/pack.rs | 14 +- apps/app/src/api/process.rs | 8 +- apps/app/src/api/profile.rs | 510 ----- apps/app/src/api/profile_create.rs | 44 - apps/app/src/api/worlds.rs | 58 +- apps/app/src/main.rs | 5 +- ...60526120000_fix-skin-selector-identity.sql | 3 + ...0000_finish-instance-profile-migration.sql | 129 ++ packages/app-lib/src/api/instance.rs | 1130 +++++++++++ packages/app-lib/src/api/logs.rs | 89 +- packages/app-lib/src/api/mod.rs | 17 +- .../app-lib/src/api/pack/import/atlauncher.rs | 84 +- .../app-lib/src/api/pack/import/curseforge.rs | 89 +- .../app-lib/src/api/pack/import/gdlauncher.rs | 57 +- packages/app-lib/src/api/pack/import/mmc.rs | 35 +- packages/app-lib/src/api/pack/import/mod.rs | 28 +- packages/app-lib/src/api/pack/install_from.rs | 152 +- .../app-lib/src/api/pack/install_mrpack.rs | 211 +- packages/app-lib/src/api/process.rs | 65 +- packages/app-lib/src/api/profile/create.rs | 230 --- packages/app-lib/src/api/profile/mod.rs | 1190 ----------- packages/app-lib/src/api/profile/update.rs | 242 --- packages/app-lib/src/api/settings.rs | 4 +- packages/app-lib/src/api/worlds.rs | 274 ++- packages/app-lib/src/error.rs | 9 +- packages/app-lib/src/event/emit.rs | 22 +- packages/app-lib/src/event/mod.rs | 34 +- packages/app-lib/src/launcher/args.rs | 2 +- packages/app-lib/src/launcher/download.rs | 2 +- packages/app-lib/src/launcher/mod.rs | 239 ++- .../app-lib/src/state/attached_world_data.rs | 169 +- packages/app-lib/src/state/cache.rs | 16 +- packages/app-lib/src/state/dirs.rs | 71 +- packages/app-lib/src/state/discord.rs | 19 +- packages/app-lib/src/state/friends.rs | 18 +- packages/app-lib/src/state/fs_watcher.rs | 248 --- packages/app-lib/src/state/instance_types.rs | 215 ++ .../state/instances/adapters/filesystem.rs | 84 + .../src/state/instances/adapters/mod.rs | 1 + .../instances/adapters/sqlite/content_rows.rs | 498 ++++- .../adapters/sqlite/instance_rows.rs | 523 ++--- .../commands/apply_content_install.rs | 475 +++++ .../commands/apply_content_update.rs | 63 + .../commands/check_content_updates.rs | 177 ++ .../instances/commands/create_instance.rs | 230 +++ .../state/instances/commands/edit_instance.rs | 255 +++ .../state/instances/commands/get_instance.rs | 79 + .../instances/commands/launch_context.rs | 178 ++ .../state/instances/commands/list_content.rs | 1180 +++++++++++ .../src/state/instances/commands/mod.rs | 46 +- .../instances/commands/refresh_instances.rs | 44 + .../instances/commands/remove_instance.rs | 23 + .../instances/commands/replace_modpack.rs | 194 ++ .../instances/commands/sync_content_files.rs | 89 + .../app-lib/src/state/instances/content.rs | 998 +--------- packages/app-lib/src/state/instances/ids.rs | 29 - .../app-lib/src/state/instances/legacy/mod.rs | 2 - .../instances/legacy/profile_projection.rs | 178 -- packages/app-lib/src/state/instances/mod.rs | 23 +- .../src/state/instances/model/instance.rs | 4 +- .../src/state/instances/model/launch.rs | 47 +- .../app-lib/src/state/instances/watcher.rs | 269 +++ .../app-lib/src/state/legacy_converter.rs | 390 +++- packages/app-lib/src/state/mod.rs | 19 +- packages/app-lib/src/state/process.rs | 294 +-- packages/app-lib/src/state/profiles.rs | 1754 ----------------- packages/app-lib/src/state/server_join_log.rs | 78 +- packages/app-lib/src/util/io.rs | 12 - 138 files changed, 9167 insertions(+), 7865 deletions(-) create mode 100644 apps/app-frontend/src/helpers/instance.ts delete mode 100644 apps/app-frontend/src/helpers/profile.ts create mode 100644 apps/app/src/api/instance.rs delete mode 100644 apps/app/src/api/profile.rs delete mode 100644 apps/app/src/api/profile_create.rs create mode 100644 packages/app-lib/migrations/20260619120000_finish-instance-profile-migration.sql create mode 100644 packages/app-lib/src/api/instance.rs delete mode 100644 packages/app-lib/src/api/profile/create.rs delete mode 100644 packages/app-lib/src/api/profile/mod.rs delete mode 100644 packages/app-lib/src/api/profile/update.rs delete mode 100644 packages/app-lib/src/state/fs_watcher.rs create mode 100644 packages/app-lib/src/state/instance_types.rs create mode 100644 packages/app-lib/src/state/instances/adapters/filesystem.rs create mode 100644 packages/app-lib/src/state/instances/commands/apply_content_install.rs create mode 100644 packages/app-lib/src/state/instances/commands/apply_content_update.rs create mode 100644 packages/app-lib/src/state/instances/commands/check_content_updates.rs create mode 100644 packages/app-lib/src/state/instances/commands/create_instance.rs create mode 100644 packages/app-lib/src/state/instances/commands/edit_instance.rs create mode 100644 packages/app-lib/src/state/instances/commands/get_instance.rs create mode 100644 packages/app-lib/src/state/instances/commands/launch_context.rs create mode 100644 packages/app-lib/src/state/instances/commands/list_content.rs create mode 100644 packages/app-lib/src/state/instances/commands/refresh_instances.rs create mode 100644 packages/app-lib/src/state/instances/commands/remove_instance.rs create mode 100644 packages/app-lib/src/state/instances/commands/replace_modpack.rs create mode 100644 packages/app-lib/src/state/instances/commands/sync_content_files.rs delete mode 100644 packages/app-lib/src/state/instances/ids.rs delete mode 100644 packages/app-lib/src/state/instances/legacy/mod.rs delete mode 100644 packages/app-lib/src/state/instances/legacy/profile_projection.rs create mode 100644 packages/app-lib/src/state/instances/watcher.rs delete mode 100644 packages/app-lib/src/state/profiles.rs diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index dc727147c8..83aa968c3b 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -94,9 +94,9 @@ import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics' import { check_reachable } from '@/helpers/auth.js' import { get_user, get_version } from '@/helpers/cache.js' import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js' +import { list } from '@/helpers/instance' import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts' -import { create_profile_and_install_from_file } from '@/helpers/pack' -import { list } from '@/helpers/profile.js' +import { create_instance_and_install_from_file } from '@/helpers/pack' import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts' import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get_opening_command, initialize_state } from '@/helpers/state' @@ -852,8 +852,8 @@ async function handleCommand(e) { if (e.event === 'RunMRPack') { // RunMRPack should directly install a local mrpack given a path if (e.path.endsWith('.mrpack')) { - await create_profile_and_install_from_file(e.path, (createProfile, fileName) => - unknownPackWarningModal.value?.show(createProfile, fileName), + await create_instance_and_install_from_file(e.path, (createInstance, fileName) => + unknownPackWarningModal.value?.show(createInstance, fileName), ).catch(handleError) trackEvent('InstanceCreate', { source: 'CreationModalFileDrop', diff --git a/apps/app-frontend/src/components/GridDisplay.vue b/apps/app-frontend/src/components/GridDisplay.vue index 3d6dc42a13..b407ee483c 100644 --- a/apps/app-frontend/src/components/GridDisplay.vue +++ b/apps/app-frontend/src/components/GridDisplay.vue @@ -24,7 +24,7 @@ import { computed, ref } from 'vue' import ContextMenu from '@/components/ui/ContextMenu.vue' import Instance from '@/components/ui/Instance.vue' import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue' -import { duplicate, remove } from '@/helpers/profile.js' +import { duplicate, remove } from '@/helpers/instance' const { handleError } = injectNotificationManager() @@ -48,21 +48,21 @@ const instanceComponents = ref(null) const currentDeleteInstance = ref(null) const confirmModal = ref(null) -async function deleteProfile() { +async function deleteInstance() { if (currentDeleteInstance.value) { instanceComponents.value = instanceComponents.value.filter( - (x) => x.instance.path !== currentDeleteInstance.value, + (x) => x.instance.id !== currentDeleteInstance.value, ) await remove(currentDeleteInstance.value).catch(handleError) } } -async function duplicateProfile(p) { +async function duplicateInstance(p) { await duplicate(p).catch(handleError) } -const handleRightClick = (event, profilePathId) => { - const item = instanceComponents.value.find((x) => x.instance.path === profilePathId) +const handleRightClick = (event, instanceId) => { + const item = instanceComponents.value.find((x) => x.instance.id === instanceId) const baseOptions = [ { name: 'add_content' }, { type: 'divider' }, @@ -114,16 +114,16 @@ const handleOptionsClick = async (args) => { break case 'duplicate': if (args.item.instance.install_stage == 'installed') - await duplicateProfile(args.item.instance.path) + await duplicateInstance(args.item.instance.id) break case 'open': await args.item.openFolder() break case 'copy': - await navigator.clipboard.writeText(args.item.instance.path) + await navigator.clipboard.writeText(args.item.instance.id) break case 'delete': - currentDeleteInstance.value = args.item.instance.path + currentDeleteInstance.value = args.item.instance.id confirmModal.value.show() break } @@ -321,13 +321,13 @@ const filteredResults = computed(() => { - + diff --git a/apps/app-frontend/src/components/RowDisplay.vue b/apps/app-frontend/src/components/RowDisplay.vue index 8e3a4cc276..0d0c47988d 100644 --- a/apps/app-frontend/src/components/RowDisplay.vue +++ b/apps/app-frontend/src/components/RowDisplay.vue @@ -21,9 +21,9 @@ import Instance from '@/components/ui/Instance.vue' import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue' import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue' import { trackEvent } from '@/helpers/analytics' -import { get_by_profile_path } from '@/helpers/process.js' -import { duplicate, kill, remove, run } from '@/helpers/profile.js' -import { showProfileInFolder } from '@/helpers/utils.js' +import { duplicate, kill, remove, run } from '@/helpers/instance' +import { get_by_instance_id } from '@/helpers/process.js' +import { showInstanceInFolder } from '@/helpers/utils.js' import { injectContentInstall } from '@/providers/content-install' import { handleSevereError } from '@/store/error.js' @@ -60,13 +60,13 @@ const deleteConfirmModal = ref(null) const currentDeleteInstance = ref(null) -async function deleteProfile() { +async function deleteInstance() { if (currentDeleteInstance.value) { await remove(currentDeleteInstance.value).catch(handleError) } } -async function duplicateProfile(p) { +async function duplicateInstance(p) { await duplicate(p).catch(handleError) } @@ -85,7 +85,7 @@ const handleInstanceRightClick = async (event, passedInstance) => { }, ] - const runningProcesses = await get_by_profile_path(passedInstance.path).catch(handleError) + const runningProcesses = await get_by_instance_id(passedInstance.id).catch(handleError) const options = runningProcesses.length > 0 @@ -126,16 +126,14 @@ const handleProjectClick = (event, passedInstance) => { const handleOptionsClick = async (args) => { switch (args.option) { case 'play': - await run(args.item.path).catch((err) => - handleSevereError(err, { profilePath: args.item.path }), - ) + await run(args.item.id).catch((err) => handleSevereError(err, { instanceId: args.item.id })) trackEvent('InstanceStart', { loader: args.item.loader, game_version: args.item.game_version, }) break case 'stop': - await kill(args.item.path).catch(handleError) + await kill(args.item.id).catch(handleError) trackEvent('InstanceStop', { loader: args.item.loader, game_version: args.item.game_version, @@ -144,26 +142,26 @@ const handleOptionsClick = async (args) => { case 'add_content': await router.push({ path: `/browse/${args.item.loader === 'vanilla' ? 'datapack' : 'mod'}`, - query: { i: args.item.path }, + query: { i: args.item.id }, }) break case 'edit': await router.push({ - path: `/instance/${encodeURIComponent(args.item.path)}`, + path: `/instance/${encodeURIComponent(args.item.id)}`, }) break case 'duplicate': - if (args.item.install_stage == 'installed') await duplicateProfile(args.item.path) + if (args.item.install_stage == 'installed') await duplicateInstance(args.item.id) break case 'delete': - currentDeleteInstance.value = args.item.path + currentDeleteInstance.value = args.item.id deleteConfirmModal.value.show() break case 'open_folder': - await showProfileInFolder(args.item.path) + await showInstanceInFolder(args.item.id) break case 'copy_path': - await navigator.clipboard.writeText(args.item.path) + await navigator.clipboard.writeText(args.item.id) break case 'install': { await installVersion( @@ -239,7 +237,7 @@ onUnmounted(() => {