diff --git a/apps/app/build.rs b/apps/app/build.rs index 3d33989..3aee07d 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -534,6 +534,12 @@ fn main() { "instance_delete_screenshot_group", "instance_set_screenshot_group_memberships", "instance_import_screenshot_groups", + "instance_list_groups", + "instance_create_group", + "instance_rename_group", + "instance_delete_group", + "instance_set_group_order", + "instance_set_group_memberships", "instance_delete_screenshots", "instance_export_screenshots", "instance_move_screenshots", diff --git a/apps/app/src/api/instance/mod.rs b/apps/app/src/api/instance/mod.rs index ea81611..20c4d0e 100644 --- a/apps/app/src/api/instance/mod.rs +++ b/apps/app/src/api/instance/mod.rs @@ -131,6 +131,12 @@ pub fn init() -> tauri::plugin::TauriPlugin { instance_delete_screenshot_group, instance_set_screenshot_group_memberships, instance_import_screenshot_groups, + instance_list_groups, + instance_create_group, + instance_rename_group, + instance_delete_group, + instance_set_group_order, + instance_set_group_memberships, instance_delete_screenshots, instance_export_screenshots, instance_move_screenshots, @@ -1717,6 +1723,46 @@ pub async fn instance_import_screenshot_groups( Ok(theseus::instance::import_screenshot_groups(groups).await?) } +#[tauri::command] +pub async fn instance_list_groups() +-> Result> { + Ok(theseus::instance::list_groups().await?) +} + +#[tauri::command] +pub async fn instance_create_group( + name: String, +) -> Result { + Ok(theseus::instance::create_group(name).await?) +} + +#[tauri::command] +pub async fn instance_rename_group( + id: String, + new_name: String, +) -> Result { + Ok(theseus::instance::rename_group(id, new_name).await?) +} + +#[tauri::command] +pub async fn instance_delete_group(id: String) -> Result<()> { + Ok(theseus::instance::delete_group(id).await?) +} + +#[tauri::command] +pub async fn instance_set_group_order( + group_ids: Vec, +) -> Result<()> { + Ok(theseus::instance::set_group_order(group_ids).await?) +} + +#[tauri::command] +pub async fn instance_set_group_memberships( + updates: Vec, +) -> Result<()> { + Ok(theseus::instance::set_group_memberships(updates).await?) +} + #[tauri::command] pub async fn instance_delete_screenshots( keys: Vec, diff --git a/packages/app-lib/src/api/instance/groups.rs b/packages/app-lib/src/api/instance/groups.rs new file mode 100644 index 0000000..ee6b3c0 --- /dev/null +++ b/packages/app-lib/src/api/instance/groups.rs @@ -0,0 +1,261 @@ +use crate::event::emit::emit_instance_groups_changed; +use crate::state::instance_groups; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use uuid::Uuid; + +const MAX_GROUP_NAME_LENGTH: usize = 256; +pub const FAVORITES_GROUP_ID: &str = instance_groups::FAVORITES_GROUP_ID; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct InstanceGroup { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct InstanceGroupMembershipUpdate { + pub instance_id: String, + pub group_ids: Vec, +} + +fn validate_group_name(name: &str) -> crate::Result<&str> { + let name = name.trim(); + + if name.is_empty() { + return Err(crate::ErrorKind::InputError( + "Group name cannot be empty".to_string(), + ) + .into()); + } + + if name.chars().count() > MAX_GROUP_NAME_LENGTH { + return Err(crate::ErrorKind::InputError(format!( + "Group name cannot exceed {MAX_GROUP_NAME_LENGTH} characters" + )) + .into()); + } + + if name.eq_ignore_ascii_case("none") { + return Err(crate::ErrorKind::InputError( + "Group name cannot be None".to_string(), + ) + .into()); + } + + Ok(name) +} + +fn normalize_membership_updates( + mut updates: Vec, +) -> crate::Result> { + let mut instance_ids = HashSet::new(); + for update in &mut updates { + if !instance_ids.insert(update.instance_id.clone()) { + return Err(crate::ErrorKind::InputError(format!( + "Duplicate instance {} in group membership update", + update.instance_id + )) + .into()); + } + + let mut group_ids = HashSet::new(); + update + .group_ids + .retain(|group_id| group_ids.insert(group_id.clone())); + } + + Ok(updates) +} + +pub async fn list_groups() -> crate::Result> { + Ok(instance_groups::list() + .into_iter() + .map(|group| InstanceGroup { + id: group.id, + name: group.name, + }) + .collect()) +} + +pub async fn create_group(name: String) -> crate::Result { + let name = validate_group_name(&name)?; + let id = Uuid::new_v4().to_string(); + instance_groups::create(&id, name); + + Ok(InstanceGroup { + id, + name: name.to_string(), + }) +} + +pub async fn set_group_order(group_ids: Vec) -> crate::Result<()> { + instance_groups::set_order(&group_ids); + Ok(()) +} + +pub async fn set_group_memberships( + updates: Vec, +) -> crate::Result<()> { + if updates.is_empty() { + return Ok(()); + } + + let updates = normalize_membership_updates(updates)?; + + // Note: the instances themselves are deliberately not validated here. + // JSON-backed instances (`local:` ids) have no row in the `instances` table, + // and requiring one is what used to make grouping them fail outright. + let unique_group_ids = updates + .iter() + .flat_map(|update| update.group_ids.iter()) + .collect::>(); + for group_id in unique_group_ids { + if !instance_groups::group_exists(group_id) { + return Err(crate::ErrorKind::InputError(format!( + "Unknown instance group {group_id}" + )) + .into()); + } + } + + instance_groups::set_memberships( + &updates + .iter() + .map(|update| { + (update.instance_id.clone(), update.group_ids.clone()) + }) + .collect::>(), + ); + + let instance_ids = updates + .into_iter() + .map(|update| update.instance_id) + .collect::>(); + emit_instance_groups_changed(&instance_ids).await?; + + Ok(()) +} + +pub async fn rename_group( + id: String, + new_name: String, +) -> crate::Result { + if id == FAVORITES_GROUP_ID { + return Err(crate::ErrorKind::InputError( + "Favorites cannot be renamed".to_string(), + ) + .into()); + } + + let new_name = validate_group_name(&new_name)?; + + let Some(instance_ids) = instance_groups::rename(&id, new_name) else { + return Err(crate::ErrorKind::InputError(format!( + "Unknown instance group {id}" + )) + .into()); + }; + + emit_instance_groups_changed(&instance_ids).await?; + + Ok(InstanceGroup { + id, + name: new_name.to_string(), + }) +} + +pub async fn delete_group(id: String) -> crate::Result<()> { + if id == FAVORITES_GROUP_ID { + return Err(crate::ErrorKind::InputError( + "Favorites cannot be deleted".to_string(), + ) + .into()); + } + + let Some(instance_ids) = instance_groups::delete(&id) else { + return Err(crate::ErrorKind::InputError(format!( + "Unknown instance group {id}" + )) + .into()); + }; + + emit_instance_groups_changed(&instance_ids).await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + FAVORITES_GROUP_ID, InstanceGroupMembershipUpdate, + MAX_GROUP_NAME_LENGTH, normalize_membership_updates, + validate_group_name, + }; + + #[test] + fn group_name_validation_trims_valid_names() { + assert_eq!(validate_group_name(" My group ").unwrap(), "My group"); + } + + #[test] + fn group_name_validation_rejects_empty_names() { + assert!(validate_group_name(" ").is_err()); + } + + #[test] + fn group_name_validation_rejects_reserved_name() { + assert!(validate_group_name("NoNe").is_err()); + } + + #[test] + fn group_name_validation_allows_favorites() { + assert_eq!(validate_group_name("Favorites").unwrap(), "Favorites"); + } + + #[test] + fn group_name_validation_rejects_long_names() { + assert!( + validate_group_name(&"a".repeat(MAX_GROUP_NAME_LENGTH + 1)) + .is_err() + ); + } + + #[test] + fn favorites_group_id_is_stable() { + assert_eq!(FAVORITES_GROUP_ID, "group:favorites"); + } + + #[test] + fn membership_updates_reject_duplicate_instances() { + let updates = vec![ + InstanceGroupMembershipUpdate { + instance_id: "instance".to_string(), + group_ids: vec!["first".to_string()], + }, + InstanceGroupMembershipUpdate { + instance_id: "instance".to_string(), + group_ids: vec!["second".to_string()], + }, + ]; + + assert!(normalize_membership_updates(updates).is_err()); + } + + #[test] + fn membership_updates_deduplicate_groups() { + let updates = + normalize_membership_updates(vec![InstanceGroupMembershipUpdate { + instance_id: "instance".to_string(), + group_ids: vec![ + "a".to_string(), + "b".to_string(), + "a".to_string(), + "b".to_string(), + ], + }]) + .unwrap(); + + assert_eq!(updates[0].group_ids, &["a", "b"]); + } +} diff --git a/packages/app-lib/src/api/instance/mod.rs b/packages/app-lib/src/api/instance/mod.rs index 7779ae6..6d7b187 100644 --- a/packages/app-lib/src/api/instance/mod.rs +++ b/packages/app-lib/src/api/instance/mod.rs @@ -5,6 +5,7 @@ mod content; mod core_components; mod export_mrpack; mod get; +mod groups; mod home; mod install; mod lifecycle; @@ -72,6 +73,11 @@ pub use self::export_mrpack::{ create_mrpack_json, export_mrpack, get_pack_export_candidates, }; pub use self::get::{get, get_many, list}; +pub use self::groups::{ + FAVORITES_GROUP_ID, InstanceGroup, InstanceGroupMembershipUpdate, + create_group, delete_group, list_groups, rename_group, + set_group_memberships, set_group_order, +}; pub use self::home::{ get_daily_playtime, get_daily_playtime_details, set_pinned, }; diff --git a/packages/app-lib/src/event/emit.rs b/packages/app-lib/src/event/emit.rs index 96e21c5..368d6b7 100644 --- a/packages/app-lib/src/event/emit.rs +++ b/packages/app-lib/src/event/emit.rs @@ -6,11 +6,10 @@ use crate::event::{ }; #[cfg(feature = "tauri")] use crate::event::{ - InstancePayload, JavaDiscoveryPayload, JavaDownloadConfirmationPayload, - LoadingPayload, ProcessPayload, ServerPayload, WarningPayload, + InstanceGroupsChangedPayload, InstancePayload, JavaDiscoveryPayload, + JavaDownloadConfirmationPayload, LoadingPayload, ProcessPayload, + ServerPayload, WarningPayload, }; -use futures::prelude::*; -use serde_json::Value; #[cfg(feature = "tauri")] use std::sync::LazyLock; #[cfg(feature = "tauri")] @@ -488,6 +487,26 @@ pub async fn emit_friend(payload: FriendPayload) -> crate::Result<()> { Ok(()) } +#[allow(unused_variables)] +pub async fn emit_instance_groups_changed( + instance_ids: &[String], +) -> crate::Result<()> { + #[cfg(feature = "tauri")] + { + let event_state = crate::EventState::get()?; + event_state + .app + .emit( + "instance_groups_changed", + InstanceGroupsChangedPayload { + instance_ids: instance_ids.to_vec(), + }, + ) + .map_err(EventError::from)?; + } + Ok(()) +} + #[allow(unused_variables)] pub async fn emit_notification(payload: Value) -> crate::Result<()> { #[cfg(feature = "tauri")] diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs index fc4b130..d575c34 100644 --- a/packages/app-lib/src/event/mod.rs +++ b/packages/app-lib/src/event/mod.rs @@ -395,6 +395,10 @@ pub enum ServerPayloadType { eula_text: String, }, } +#[derive(Serialize, Clone)] +pub struct InstanceGroupsChangedPayload { + pub instance_ids: Vec, +} #[derive(Serialize, Clone)] #[cfg(feature = "tauri")] diff --git a/packages/app-lib/src/state/instance_groups.rs b/packages/app-lib/src/state/instance_groups.rs new file mode 100644 index 0000000..beca271 --- /dev/null +++ b/packages/app-lib/src/state/instance_groups.rs @@ -0,0 +1,613 @@ +//! Instance grouping backed by a standalone JSON file in the app's settings +//! directory, next to `settings.json`. +//! +//! Groups used to live in SQLite (`instance_groups` / +//! `instance_group_memberships`). That layout could not express membership for +//! JSON-backed instances (`local:` ids, i.e. every `.minecraft` and +//! multi-library instance) because they have no row in the `instances` table, +//! and it duplicated membership into the per-instance sidecars. This module is +//! the single source of truth instead; the SQL tables are left untouched but +//! are no longer read or written (they are only read once, at import time). + +use crate::state::State; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::PathBuf; +use std::sync::{OnceLock, RwLock}; +use tracing::{info, warn}; + +pub const INSTANCE_GROUPS_FILE_NAME: &str = "instance_groups.json"; + +/// Current `instance_groups.json` schema version. +pub const INSTANCE_GROUPS_SCHEMA_VERSION: u32 = 1; + +pub const FAVORITES_GROUP_ID: &str = "group:favorites"; +const FAVORITES_GROUP_NAME: &str = "Pinned"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GroupDefinition { + pub id: String, + pub name: String, +} + +/// On-disk shape. The order of `groups` *is* the display order, so no +/// `display_order` field is needed: the public API only ever communicates +/// ordering through the order of `list()` and `set_order()`. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct InstanceGroupsFile { + #[serde(default)] + pub schema_version: u32, + #[serde(default)] + pub groups: Vec, + /// instance id -> group ids. + #[serde(default)] + pub memberships: HashMap>, +} + +impl Default for InstanceGroupsFile { + fn default() -> Self { + Self { + schema_version: INSTANCE_GROUPS_SCHEMA_VERSION, + groups: vec![GroupDefinition { + id: FAVORITES_GROUP_ID.to_string(), + name: FAVORITES_GROUP_NAME.to_string(), + }], + memberships: HashMap::new(), + } + } +} + +impl InstanceGroupsFile { + fn has_group(&self, id: &str) -> bool { + self.groups.iter().any(|group| group.id == id) + } + + /// Guarantees the favorites sentinel exists and sits first, drops + /// memberships pointing at groups that no longer exist, and drops instances + /// left with no groups at all. + fn normalize(&mut self) { + self.schema_version = INSTANCE_GROUPS_SCHEMA_VERSION; + + match self.groups.iter().position(|g| g.id == FAVORITES_GROUP_ID) { + Some(0) => {} + Some(index) => { + let favorites = self.groups.remove(index); + self.groups.insert(0, favorites); + } + None => self.groups.insert( + 0, + GroupDefinition { + id: FAVORITES_GROUP_ID.to_string(), + name: FAVORITES_GROUP_NAME.to_string(), + }, + ), + } + + let known = self + .groups + .iter() + .map(|group| group.id.clone()) + .collect::>(); + + for group_ids in self.memberships.values_mut() { + let mut seen = HashSet::new(); + group_ids + .retain(|id| known.contains(id) && seen.insert(id.clone())); + } + self.memberships + .retain(|_, group_ids| !group_ids.is_empty()); + } +} + +static STORE: OnceLock> = OnceLock::new(); + +fn file_path() -> Option { + let state = State::get_if_initialized()?; + Some( + state + .directories + .settings_dir + .join(INSTANCE_GROUPS_FILE_NAME), + ) +} + +/// Reads the store from disk, or `None` when it has never been written. +fn read_from_disk() -> Option { + let path = file_path()?; + if !path.exists() { + return None; + } + match fs::read_to_string(&path) + .map_err(|e| e.to_string()) + .and_then(|content| { + serde_json::from_str::(&content) + .map_err(|e| e.to_string()) + }) { + Ok(mut file) => { + file.normalize(); + Some(file) + } + Err(error) => { + // A corrupt file must not wipe the user's groups silently on the + // next write, so bail out to an empty store without touching disk. + warn!("Failed to read {INSTANCE_GROUPS_FILE_NAME}: {error}"); + None + } + } +} + +fn write_to_disk(file: &InstanceGroupsFile) { + let Some(path) = file_path() else { + return; + }; + let write = serde_json::to_string_pretty(file) + .map_err(|e| e.to_string()) + .and_then(|content| { + fs::write(&path, content).map_err(|e| e.to_string()) + }); + if let Err(error) = write { + warn!("Failed to save {INSTANCE_GROUPS_FILE_NAME}: {error}"); + } +} + +fn store() -> &'static RwLock { + STORE.get_or_init(|| RwLock::new(read_from_disk().unwrap_or_default())) +} + +fn with_read(f: impl FnOnce(&InstanceGroupsFile) -> T) -> T { + let guard = store().read().unwrap_or_else(|e| e.into_inner()); + f(&guard) +} + +/// Mutates the store and persists the whole file. `f` returns the instance ids +/// whose membership changed, so callers can emit the change event. +fn with_write(f: impl FnOnce(&mut InstanceGroupsFile) -> T) -> T { + let mut guard = store().write().unwrap_or_else(|e| e.into_inner()); + let result = f(&mut guard); + guard.normalize(); + write_to_disk(&guard); + result +} + +// ── Reads ─────────────────────────────────────────────────────────────────── + +pub fn list() -> Vec { + with_read(|file| file.groups.clone()) +} + +pub fn group_exists(id: &str) -> bool { + with_read(|file| file.has_group(id)) +} + +/// Group ids for one instance, in the order they were assigned. Synchronous so +/// that `instance_metadata_from_instance` can call it. +pub fn group_ids_for(instance_id: &str) -> Vec { + with_read(|file| { + file.memberships + .get(instance_id) + .cloned() + .unwrap_or_default() + }) +} + +fn instances_in_group( + file: &InstanceGroupsFile, + group_id: &str, +) -> Vec { + file.memberships + .iter() + .filter(|(_, group_ids)| group_ids.iter().any(|id| id == group_id)) + .map(|(instance_id, _)| instance_id.clone()) + .collect() +} + +// ── Writes ────────────────────────────────────────────────────────────────── + +/// Adds a group at the top of the list, matching the previous SQL behavior +/// where new groups got the lowest `display_order`. Favorites stays first. +pub fn create(id: &str, name: &str) { + with_write(|file| { + let definition = GroupDefinition { + id: id.to_string(), + name: name.to_string(), + }; + // `normalize` moves favorites back to index 0 afterwards. + file.groups.insert(0, definition); + }); +} + +/// Renames a group, returning the affected instance ids, or `None` when the +/// group does not exist. +pub fn rename(id: &str, name: &str) -> Option> { + with_write(|file| { + let group = file.groups.iter_mut().find(|group| group.id == id)?; + group.name = name.to_string(); + Some(instances_in_group(file, id)) + }) +} + +/// Deletes a group and strips it from every instance, returning the affected +/// instance ids, or `None` when the group does not exist. +pub fn delete(id: &str) -> Option> { + with_write(|file| { + let index = file.groups.iter().position(|group| group.id == id)?; + let affected = instances_in_group(file, id); + file.groups.remove(index); + // `normalize` prunes the now-dangling ids out of `memberships`. + Some(affected) + }) +} + +/// Reorders groups to match `group_ids`. Groups missing from the argument keep +/// their relative order at the end, so a stale frontend list can never drop a +/// group. Favorites is pinned first regardless. +pub fn set_order(group_ids: &[String]) { + with_write(|file| reorder_groups(&mut file.groups, group_ids)); +} + +/// Reorders `groups` to match `group_ids`. Groups the argument omits keep their +/// relative order after the listed ones, so a stale frontend list can never drop +/// a group. Pure so the ordering rules can be tested without the process-wide +/// store, which tests would otherwise race over. +fn reorder_groups(groups: &mut Vec, group_ids: &[String]) { + let mut remaining = std::mem::take(groups); + let mut ordered = Vec::with_capacity(remaining.len()); + for id in group_ids { + if id == FAVORITES_GROUP_ID { + continue; + } + if let Some(index) = remaining.iter().position(|group| &group.id == id) + { + ordered.push(remaining.remove(index)); + } + } + ordered.extend(remaining); + *groups = ordered; +} + +/// Replaces the group list of each given instance wholesale. +pub fn set_memberships(updates: &[(String, Vec)]) { + with_write(|file| { + for (instance_id, group_ids) in updates { + if group_ids.is_empty() { + file.memberships.remove(instance_id); + } else { + file.memberships + .insert(instance_id.clone(), group_ids.clone()); + } + } + }); +} + +/// Replaces the group list of a single instance. +pub fn set_instance_groups(instance_id: &str, group_ids: &[String]) { + set_memberships(&[(instance_id.to_string(), group_ids.to_vec())]); +} + +/// Looks a group up by name, creating it if absent, and returns its id. +/// Used by the legacy launcher import, which only knows group names. +pub fn find_or_create_by_name(name: &str) -> String { + with_write(|file| { + if let Some(group) = file.groups.iter().find(|group| group.name == name) + { + return group.id.clone(); + } + let id = uuid::Uuid::new_v4().to_string(); + file.groups.push(GroupDefinition { + id: id.clone(), + name: name.to_string(), + }); + id + }) +} + +/// Adds one group to an instance, keeping its existing groups. +pub fn add_instance_to_group(instance_id: &str, group_id: &str) { + with_write(|file| { + let entry = + file.memberships.entry(instance_id.to_string()).or_default(); + if !entry.iter().any(|id| id == group_id) { + entry.push(group_id.to_string()); + } + }); +} + +/// Drops an instance's membership entry, e.g. after the instance is deleted. +pub fn forget_instance(instance_id: &str) { + with_write(|file| { + file.memberships.remove(instance_id); + }); +} + +/// Re-keys an instance's membership, e.g. when a `local:` id changes because the +/// instance directory moved. +pub fn rename_instance(old_id: &str, new_id: &str) { + if old_id == new_id { + return; + } + with_write(|file| { + if let Some(group_ids) = file.memberships.remove(old_id) { + file.memberships.insert(new_id.to_string(), group_ids); + } + }); +} + +// ── One-time import ───────────────────────────────────────────────────────── + +async fn import_sql_groups( + pool: &sqlx::SqlitePool, +) -> crate::Result { + let columns: Vec = sqlx::query_scalar( + "SELECT name FROM pragma_table_info('instance_groups')", + ) + .fetch_all(pool) + .await?; + let has_column = |name: &str| columns.iter().any(|column| column == name); + + let mut file = InstanceGroupsFile { + schema_version: INSTANCE_GROUPS_SCHEMA_VERSION, + groups: Vec::new(), + memberships: HashMap::new(), + }; + + if has_column("id") && has_column("name") { + let rows = sqlx::query_as::<_, (String, String)>( + "SELECT id, name FROM instance_groups ORDER BY display_order, name, id", + ) + .fetch_all(pool) + .await?; + for (id, name) in rows { + file.groups.push(GroupDefinition { id, name }); + } + + let has_memberships: bool = sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = 'instance_group_memberships' + )", + ) + .fetch_one(pool) + .await?; + if has_memberships { + let memberships = sqlx::query_as::<_, (String, String)>( + "SELECT instance_id, group_id FROM instance_group_memberships", + ) + .fetch_all(pool) + .await?; + for (instance_id, group_id) in memberships { + file.memberships + .entry(instance_id) + .or_default() + .push(group_id); + } + } + } else if has_column("instance_id") && has_column("group_name") { + let rows = sqlx::query_as::<_, (String, String)>( + "SELECT instance_id, group_name + FROM instance_groups + ORDER BY group_name, instance_id", + ) + .fetch_all(pool) + .await?; + let mut group_ids = HashMap::::new(); + for (instance_id, group_name) in rows { + let group_id = group_ids + .entry(group_name.clone()) + .or_insert_with(|| uuid::Uuid::new_v4().to_string()) + .clone(); + file.memberships + .entry(instance_id) + .or_default() + .push(group_id.clone()); + if !file.groups.iter().any(|group| group.id == group_id) { + file.groups.push(GroupDefinition { + id: group_id, + name: group_name, + }); + } + } + } else if !columns.is_empty() { + warn!( + columns = ?columns, + "Skipping instance group import from an unrecognized SQLite schema" + ); + } + + file.normalize(); + Ok(file) +} + +/// Creates `instance_groups.json` from the pre-existing SQLite tables and +/// instance sidecars, the first time the app runs after this change. +/// +/// The SQL access here is read-only and happens exactly once; afterwards the +/// tables are never touched again (they are kept as-is so no DB migration is +/// needed). +pub async fn ensure_imported(state: &State) -> crate::Result<()> { + let Some(path) = file_path() else { + return Ok(()); + }; + if path.exists() { + // Populate the in-memory cache eagerly so the first synchronous read + // does not race with a write. + let _ = list(); + return Ok(()); + } + + let file = import_sql_groups(&state.pool).await?; + let group_count = file.groups.len(); + let instance_count = file.memberships.len(); + + // Seed the cache with the imported data instead of letting `store()` read + // the file we are about to write. + { + let lock = store(); + let mut guard = lock.write().unwrap_or_else(|e| e.into_inner()); + *guard = file; + write_to_disk(&guard); + } + + info!( + "Imported {group_count} instance groups covering {instance_count} instances into {INSTANCE_GROUPS_FILE_NAME}" + ); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqlitePoolOptions; + + fn file_with_groups(ids: &[&str]) -> InstanceGroupsFile { + InstanceGroupsFile { + schema_version: INSTANCE_GROUPS_SCHEMA_VERSION, + groups: ids + .iter() + .map(|id| GroupDefinition { + id: id.to_string(), + name: id.to_string(), + }) + .collect(), + memberships: HashMap::new(), + } + } + + #[test] + fn normalize_inserts_favorites_first() { + let mut file = file_with_groups(&["a", "b"]); + file.normalize(); + assert_eq!(file.groups[0].id, FAVORITES_GROUP_ID); + assert_eq!(file.groups.len(), 3); + } + + #[test] + fn normalize_moves_favorites_to_front() { + let mut file = file_with_groups(&["a", FAVORITES_GROUP_ID, "b"]); + file.normalize(); + assert_eq!(file.groups[0].id, FAVORITES_GROUP_ID); + assert_eq!(file.groups[1].id, "a"); + assert_eq!(file.groups[2].id, "b"); + } + + #[test] + fn normalize_drops_orphan_memberships() { + let mut file = file_with_groups(&["a"]); + file.memberships.insert( + "instance".to_string(), + vec!["a".to_string(), "nonexistent".to_string()], + ); + + file.normalize(); + assert_eq!(file.memberships["instance"], &["a"]); + } + + #[test] + fn normalize_drops_memberships_for_nonexistent_instances() { + let mut file = file_with_groups(&["a"]); + file.memberships.insert( + "instance".to_string(), + vec!["nonexistent1".to_string(), "nonexistent2".to_string()], + ); + file.normalize(); + assert!(!file.memberships.contains_key("instance")); + } + + fn group_ids(groups: &[GroupDefinition]) -> Vec { + groups.iter().map(|group| group.id.clone()).collect() + } + + #[test] + fn set_order_ignores_unknown_ids() { + let mut groups = file_with_groups(&["a", "b", "c"]).groups; + + reorder_groups(&mut groups, &["unknown".to_string(), "c".to_string()]); + + // The unknown id matches nothing and simply contributes no group. + assert_eq!(group_ids(&groups), vec!["c", "a", "b"]); + } + + #[test] + fn set_order_retains_missing_groups_at_end() { + let mut groups = file_with_groups(&["a", "b", "c"]).groups; + groups.push(GroupDefinition { + id: "extra".to_string(), + name: "extra".to_string(), + }); + + reorder_groups(&mut groups, &["b".to_string()]); + + // `b` moves to the front and every group the argument omitted survives + // after it, so a stale list can never drop a group. Favorites is pinned + // by `normalize`, which the store applies around this call. + assert_eq!(group_ids(&groups), vec!["b", "a", "c", "extra"]); + } + + #[test] + fn set_order_keeps_favorites_out_of_the_reorder() { + let mut groups = file_with_groups(&["a", FAVORITES_GROUP_ID]).groups; + + reorder_groups(&mut groups, &[FAVORITES_GROUP_ID.to_string()]); + + // Favorites is skipped explicitly and survives at its previous position; + // `normalize` moves it to the front afterwards. + assert_eq!(group_ids(&groups), vec!["a", FAVORITES_GROUP_ID]); + } + + #[test] + fn normalize_does_not_lose_favorites_membership() { + let mut file = file_with_groups(&[]); + file.memberships.insert( + "my_instance".to_string(), + vec![FAVORITES_GROUP_ID.to_string()], + ); + file.normalize(); + assert_eq!(file.memberships["my_instance"], &[FAVORITES_GROUP_ID]); + } + + #[test] + fn normalize_deduplicates_group_ids() { + let mut file = file_with_groups(&["a", "b"]); + file.memberships.insert( + "instance".to_string(), + vec!["a".to_string(), "a".to_string(), "b".to_string()], + ); + file.normalize(); + assert_eq!(file.memberships["instance"].len(), 2); + } + + #[tokio::test] + async fn imports_legacy_instance_group_schema() { + let pool = SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::query( + "CREATE TABLE instance_groups ( + instance_id TEXT NOT NULL, + group_name TEXT NOT NULL, + PRIMARY KEY (instance_id, group_name) + )", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO instance_groups (instance_id, group_name) + VALUES ('instance-1', 'Survival'), ('instance-2', 'Survival'), + ('instance-1', 'Creative')", + ) + .execute(&pool) + .await + .unwrap(); + + let file = import_sql_groups(&pool).await.unwrap(); + + assert_eq!(file.groups.len(), 3); + assert_eq!(file.groups[0].id, FAVORITES_GROUP_ID); + assert_eq!(file.memberships["instance-1"].len(), 2); + assert_eq!(file.memberships["instance-2"].len(), 1); + assert!(file.groups.iter().any(|group| group.name == "Survival")); + } +} diff --git a/packages/app-lib/src/state/instances/commands/get_instance.rs b/packages/app-lib/src/state/instances/commands/get_instance.rs index 24ce226..d37df1e 100644 --- a/packages/app-lib/src/state/instances/commands/get_instance.rs +++ b/packages/app-lib/src/state/instances/commands/get_instance.rs @@ -3,6 +3,7 @@ use crate::state::instances::{ LoaderComponent, adapters::sqlite::{instance_rows, loader_component_rows}, }; +use crate::state::instance_groups; use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; @@ -91,11 +92,12 @@ impl InstanceMetadata { record: instance_rows::InstanceMetadataRecord, loader_components: Vec, ) -> Self { + let groups = instance_groups::group_ids_for(&record.instance.id); Self { instance: record.instance, applied_content_set: record.applied_content_set, link: record.link, - groups: record.groups, + groups, launch_overrides: record.launch_overrides, loader_components, synced_options: Default::default(), diff --git a/packages/app-lib/src/state/mod.rs b/packages/app-lib/src/state/mod.rs index d0e689c..ef039e6 100644 --- a/packages/app-lib/src/state/mod.rs +++ b/packages/app-lib/src/state/mod.rs @@ -26,6 +26,7 @@ pub use self::instance_types::*; pub(crate) mod instances; pub use self::instances::*; +pub(crate) mod instance_groups; mod settings; pub use self::settings::*; @@ -487,6 +488,10 @@ impl State { .get_or_try_init(move || Self::initialize_state(app_identifier)) .await?; + if let Err(e) = instance_groups::ensure_imported(state).await { + tracing::error!("Error importing legacy instance groups: {e}"); + } + if let Err(e) = crate::install::recovery::recover_interrupted_jobs(state).await {