This commit is contained in:
1 parent
c9a2eeb53d
commit
946a477397
9 files changed
+967
-5
No files matched your search
@@ -534,6 +534,12 @@ fn main() {
|
|||||||
"instance_delete_screenshot_group",
|
"instance_delete_screenshot_group",
|
||||||
"instance_set_screenshot_group_memberships",
|
"instance_set_screenshot_group_memberships",
|
||||||
"instance_import_screenshot_groups",
|
"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_delete_screenshots",
|
||||||
"instance_export_screenshots",
|
"instance_export_screenshots",
|
||||||
"instance_move_screenshots",
|
"instance_move_screenshots",
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
instance_delete_screenshot_group,
|
instance_delete_screenshot_group,
|
||||||
instance_set_screenshot_group_memberships,
|
instance_set_screenshot_group_memberships,
|
||||||
instance_import_screenshot_groups,
|
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_delete_screenshots,
|
||||||
instance_export_screenshots,
|
instance_export_screenshots,
|
||||||
instance_move_screenshots,
|
instance_move_screenshots,
|
||||||
@@ -1717,6 +1723,46 @@ pub async fn instance_import_screenshot_groups(
|
|||||||
Ok(theseus::instance::import_screenshot_groups(groups).await?)
|
Ok(theseus::instance::import_screenshot_groups(groups).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_list_groups()
|
||||||
|
-> Result<Vec<theseus::instance::InstanceGroup>> {
|
||||||
|
Ok(theseus::instance::list_groups().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_create_group(
|
||||||
|
name: String,
|
||||||
|
) -> Result<theseus::instance::InstanceGroup> {
|
||||||
|
Ok(theseus::instance::create_group(name).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_rename_group(
|
||||||
|
id: String,
|
||||||
|
new_name: String,
|
||||||
|
) -> Result<theseus::instance::InstanceGroup> {
|
||||||
|
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<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
Ok(theseus::instance::set_group_order(group_ids).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_set_group_memberships(
|
||||||
|
updates: Vec<theseus::instance::InstanceGroupMembershipUpdate>,
|
||||||
|
) -> Result<()> {
|
||||||
|
Ok(theseus::instance::set_group_memberships(updates).await?)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn instance_delete_screenshots(
|
pub async fn instance_delete_screenshots(
|
||||||
keys: Vec<theseus::instance::ScreenshotKey>,
|
keys: Vec<theseus::instance::ScreenshotKey>,
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<InstanceGroupMembershipUpdate>,
|
||||||
|
) -> crate::Result<Vec<InstanceGroupMembershipUpdate>> {
|
||||||
|
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<Vec<InstanceGroup>> {
|
||||||
|
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<InstanceGroup> {
|
||||||
|
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<String>) -> crate::Result<()> {
|
||||||
|
instance_groups::set_order(&group_ids);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_group_memberships(
|
||||||
|
updates: Vec<InstanceGroupMembershipUpdate>,
|
||||||
|
) -> 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::<HashSet<_>>();
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let instance_ids = updates
|
||||||
|
.into_iter()
|
||||||
|
.map(|update| update.instance_id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
emit_instance_groups_changed(&instance_ids).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rename_group(
|
||||||
|
id: String,
|
||||||
|
new_name: String,
|
||||||
|
) -> crate::Result<InstanceGroup> {
|
||||||
|
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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ mod content;
|
|||||||
mod core_components;
|
mod core_components;
|
||||||
mod export_mrpack;
|
mod export_mrpack;
|
||||||
mod get;
|
mod get;
|
||||||
|
mod groups;
|
||||||
mod home;
|
mod home;
|
||||||
mod install;
|
mod install;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
@@ -72,6 +73,11 @@ pub use self::export_mrpack::{
|
|||||||
create_mrpack_json, export_mrpack, get_pack_export_candidates,
|
create_mrpack_json, export_mrpack, get_pack_export_candidates,
|
||||||
};
|
};
|
||||||
pub use self::get::{get, get_many, list};
|
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::{
|
pub use self::home::{
|
||||||
get_daily_playtime, get_daily_playtime_details, set_pinned,
|
get_daily_playtime, get_daily_playtime_details, set_pinned,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ use crate::event::{
|
|||||||
};
|
};
|
||||||
#[cfg(feature = "tauri")]
|
#[cfg(feature = "tauri")]
|
||||||
use crate::event::{
|
use crate::event::{
|
||||||
InstancePayload, JavaDiscoveryPayload, JavaDownloadConfirmationPayload,
|
InstanceGroupsChangedPayload, InstancePayload, JavaDiscoveryPayload,
|
||||||
LoadingPayload, ProcessPayload, ServerPayload, WarningPayload,
|
JavaDownloadConfirmationPayload, LoadingPayload, ProcessPayload,
|
||||||
|
ServerPayload, WarningPayload,
|
||||||
};
|
};
|
||||||
use futures::prelude::*;
|
|
||||||
use serde_json::Value;
|
|
||||||
#[cfg(feature = "tauri")]
|
#[cfg(feature = "tauri")]
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
#[cfg(feature = "tauri")]
|
#[cfg(feature = "tauri")]
|
||||||
@@ -488,6 +487,26 @@ pub async fn emit_friend(payload: FriendPayload) -> crate::Result<()> {
|
|||||||
Ok(())
|
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)]
|
#[allow(unused_variables)]
|
||||||
pub async fn emit_notification(payload: Value) -> crate::Result<()> {
|
pub async fn emit_notification(payload: Value) -> crate::Result<()> {
|
||||||
#[cfg(feature = "tauri")]
|
#[cfg(feature = "tauri")]
|
||||||
|
|||||||
@@ -395,6 +395,10 @@ pub enum ServerPayloadType {
|
|||||||
eula_text: String,
|
eula_text: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct InstanceGroupsChangedPayload {
|
||||||
|
pub instance_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
#[cfg(feature = "tauri")]
|
#[cfg(feature = "tauri")]
|
||||||
|
|||||||
@@ -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<GroupDefinition>,
|
||||||
|
/// instance id -> group ids.
|
||||||
|
#[serde(default)]
|
||||||
|
pub memberships: HashMap<String, Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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::<HashSet<_>>();
|
||||||
|
|
||||||
|
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<RwLock<InstanceGroupsFile>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn file_path() -> Option<PathBuf> {
|
||||||
|
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<InstanceGroupsFile> {
|
||||||
|
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::<InstanceGroupsFile>(&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<InstanceGroupsFile> {
|
||||||
|
STORE.get_or_init(|| RwLock::new(read_from_disk().unwrap_or_default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_read<T>(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<T>(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<GroupDefinition> {
|
||||||
|
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<String> {
|
||||||
|
with_read(|file| {
|
||||||
|
file.memberships
|
||||||
|
.get(instance_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instances_in_group(
|
||||||
|
file: &InstanceGroupsFile,
|
||||||
|
group_id: &str,
|
||||||
|
) -> Vec<String> {
|
||||||
|
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<Vec<String>> {
|
||||||
|
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<Vec<String>> {
|
||||||
|
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<GroupDefinition>, 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<String>)]) {
|
||||||
|
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<InstanceGroupsFile> {
|
||||||
|
let columns: Vec<String> = 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::<String, String>::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<String> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ use crate::state::instances::{
|
|||||||
LoaderComponent,
|
LoaderComponent,
|
||||||
adapters::sqlite::{instance_rows, loader_component_rows},
|
adapters::sqlite::{instance_rows, loader_component_rows},
|
||||||
};
|
};
|
||||||
|
use crate::state::instance_groups;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
@@ -91,11 +92,12 @@ impl InstanceMetadata {
|
|||||||
record: instance_rows::InstanceMetadataRecord,
|
record: instance_rows::InstanceMetadataRecord,
|
||||||
loader_components: Vec<LoaderComponent>,
|
loader_components: Vec<LoaderComponent>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let groups = instance_groups::group_ids_for(&record.instance.id);
|
||||||
Self {
|
Self {
|
||||||
instance: record.instance,
|
instance: record.instance,
|
||||||
applied_content_set: record.applied_content_set,
|
applied_content_set: record.applied_content_set,
|
||||||
link: record.link,
|
link: record.link,
|
||||||
groups: record.groups,
|
groups,
|
||||||
launch_overrides: record.launch_overrides,
|
launch_overrides: record.launch_overrides,
|
||||||
loader_components,
|
loader_components,
|
||||||
synced_options: Default::default(),
|
synced_options: Default::default(),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub use self::instance_types::*;
|
|||||||
pub(crate) mod instances;
|
pub(crate) mod instances;
|
||||||
pub use self::instances::*;
|
pub use self::instances::*;
|
||||||
|
|
||||||
|
pub(crate) mod instance_groups;
|
||||||
mod settings;
|
mod settings;
|
||||||
pub use self::settings::*;
|
pub use self::settings::*;
|
||||||
|
|
||||||
@@ -487,6 +488,10 @@ impl State {
|
|||||||
.get_or_try_init(move || Self::initialize_state(app_identifier))
|
.get_or_try_init(move || Self::initialize_state(app_identifier))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if let Err(e) = instance_groups::ensure_imported(state).await {
|
||||||
|
tracing::error!("Error importing legacy instance groups: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
crate::install::recovery::recover_interrupted_jobs(state).await
|
crate::install::recovery::recover_interrupted_jobs(state).await
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user