26.1.2 support

This commit is contained in:
Julian Chang 2026-07-16 01:49:33 -04:00
parent 6c7d0a1ca8
commit d593ecb09f
No known key found for this signature in database
27 changed files with 967 additions and 86 deletions

1
Cargo.lock generated
View file

@ -4599,6 +4599,7 @@ dependencies = [
"serde_json",
"sqlx",
"thiserror 2.0.18",
"tokio",
"tracing",
]

View file

@ -684,6 +684,7 @@ impl CoreBridgeRuntime {
BridgeCommand::SyncBundles => {
let state = LauncherState::get()?;
state.bundles.sync(&state.services).await?;
oneclient_core::clusters::apply_remote_migrations(&state).await?;
oneclient_core::clusters::ensure_from_bundles(&state).await?;
let changed = oneclient_core::bundles::sync_all_cluster_bundles(
state.bundles.as_ref(),

View file

@ -65,7 +65,7 @@ impl Component for ActiveClusterPanel {
.and_then(|c| parse_mc_version(&c.mc_version));
let metadata = use_version_metadata(
parsed.as_ref().map(|p| p.major),
parsed.and_then(|p| p.minor),
parsed.and_then(|p| p.key()),
active.as_ref().map(|c| c.mc_loader),
);

View file

@ -1,4 +1,5 @@
use freya::prelude::*;
use oneclient_core::VersionKey;
use oneclient_core::packages::domain::GameLoader;
use crate::components::DynamicArt;
@ -14,12 +15,12 @@ pub struct ClusterLandscapeArt {
impl ClusterLandscapeArt {
pub fn for_version(
major: u32,
minor: Option<u32>,
key: Option<VersionKey>,
loader: Option<GameLoader>,
selected: bool,
) -> Self {
Self {
art: DynamicArt::for_version(major, minor, loader).max_edge(512),
art: DynamicArt::for_version(major, key, loader).max_edge(512),
selected,
}
}

View file

@ -4,7 +4,7 @@ use freya::query::QueryStateData;
use oneclient_core::clusters::Cluster;
use oneclient_core::images::DEFAULT_IMAGE_EDGE;
use oneclient_core::packages::domain::GameLoader;
use oneclient_core::parse_mc_version;
use oneclient_core::{VersionKey, parse_mc_version};
use crate::AppAssets;
use crate::hooks::{use_cached_image, use_version_metadata};
@ -13,16 +13,16 @@ use crate::layout::HOME_BACKGROUND_ASSET;
#[derive(PartialEq, Clone)]
pub struct DynamicArt {
major: Option<u32>,
minor: Option<u32>,
key: Option<VersionKey>,
loader: Option<GameLoader>,
max_edge: u32,
}
impl DynamicArt {
pub fn for_version(major: u32, minor: Option<u32>, loader: Option<GameLoader>) -> Self {
pub fn for_version(major: u32, key: Option<VersionKey>, loader: Option<GameLoader>) -> Self {
Self {
major: Some(major),
minor,
key,
loader,
max_edge: DEFAULT_IMAGE_EDGE,
}
@ -31,7 +31,7 @@ impl DynamicArt {
pub fn for_major(major: u32) -> Self {
Self {
major: Some(major),
minor: None,
key: None,
loader: None,
max_edge: DEFAULT_IMAGE_EDGE,
}
@ -41,7 +41,7 @@ impl DynamicArt {
let parsed = parse_mc_version(&cluster.mc_version);
Self {
major: parsed.as_ref().map(|p| p.major),
minor: parsed.and_then(|p| p.minor),
key: parsed.and_then(|p| p.key()),
loader: Some(cluster.mc_loader),
max_edge: DEFAULT_IMAGE_EDGE,
}
@ -50,7 +50,7 @@ impl DynamicArt {
pub fn fallback() -> Self {
Self {
major: None,
minor: None,
key: None,
loader: None,
max_edge: DEFAULT_IMAGE_EDGE,
}
@ -63,19 +63,19 @@ impl DynamicArt {
}
pub fn use_bytes(&self) -> (String, Bytes) {
use_art_bytes(self.major, self.minor, self.loader, self.max_edge)
use_art_bytes(self.major, self.key, self.loader, self.max_edge)
}
}
pub fn use_art_bytes(
major: Option<u32>,
minor: Option<u32>,
key: Option<VersionKey>,
loader: Option<GameLoader>,
max_edge: u32,
) -> (String, Bytes) {
let fallback = use_memo(|| AppAssets::get_bytes(HOME_BACKGROUND_ASSET).unwrap_or_default());
let art_url = use_version_metadata(major, minor, loader).and_then(|m| m.art_url);
let art_url = use_version_metadata(major, key, loader).and_then(|m| m.art_url);
let image_query = use_cached_image(art_url.clone(), max_edge);

View file

@ -1,4 +1,5 @@
use freya::query::QueryStateData;
use oneclient_core::VersionKey;
use oneclient_core::VersionMetadata;
use oneclient_core::packages::domain::GameLoader;
@ -7,7 +8,7 @@ use super::use_versions;
pub fn pick_version_metadata(
list: &[VersionMetadata],
major: u32,
minor: Option<u32>,
key: Option<VersionKey>,
loader: Option<GameLoader>,
) -> Option<VersionMetadata> {
let loader_matches = |m: &VersionMetadata| match (&m.loader, loader) {
@ -15,12 +16,20 @@ pub fn pick_version_metadata(
_ => false,
};
if let Some(minor) = minor {
if let Some(hit) = list.iter().find(|m| {
m.major_version == major && m.minor_version == Some(minor) && loader_matches(m)
}) {
if let Some(key) = key {
if let Some(hit) = list
.iter()
.find(|m| m.major_version == major && m.key() == Some(key) && loader_matches(m))
{
return Some(hit.clone());
}
if let Some(hit) = list
.iter()
.find(|m| m.major_version == major && m.key() == Some(key))
{
return Some(hit.clone());
}
let (minor, _) = key;
if let Some(hit) = list
.iter()
.find(|m| m.major_version == major && m.minor_version == Some(minor))
@ -36,7 +45,7 @@ pub fn pick_version_metadata(
pub fn use_version_metadata(
major: Option<u32>,
minor: Option<u32>,
key: Option<VersionKey>,
loader: Option<GameLoader>,
) -> Option<VersionMetadata> {
let versions_query = use_versions();
@ -52,5 +61,59 @@ pub fn use_version_metadata(
_ => return None,
};
pick_version_metadata(list, major, minor, loader)
pick_version_metadata(list, major, key, loader)
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(
major: u32,
minor: Option<u32>,
patch: Option<u32>,
loader: Option<&str>,
name: &str,
) -> VersionMetadata {
VersionMetadata {
major_version: major,
minor_version: minor,
patch_version: patch,
loader: loader.map(str::to_string),
name: name.to_string(),
art_url: None,
long_description: None,
tags: Vec::new(),
}
}
fn list() -> Vec<VersionMetadata> {
vec![
entry(26, None, None, None, "major"),
entry(26, Some(1), None, Some("fabric"), "bare minor"),
entry(26, Some(1), Some(2), Some("fabric"), "patched"),
]
}
#[test]
fn patch_and_bare_minor_are_distinct() {
let bare = pick_version_metadata(&list(), 26, Some((1, None)), Some(GameLoader::Fabric));
assert_eq!(bare.unwrap().name, "bare minor");
let patched =
pick_version_metadata(&list(), 26, Some((1, Some(2))), Some(GameLoader::Fabric));
assert_eq!(patched.unwrap().name, "patched");
}
#[test]
fn unknown_patch_falls_back_to_its_minor_line() {
let hit = pick_version_metadata(&list(), 26, Some((1, Some(9))), Some(GameLoader::Fabric));
assert_eq!(hit.unwrap().name, "bare minor");
}
#[test]
fn unknown_minor_falls_back_to_major() {
let hit = pick_version_metadata(&list(), 26, Some((7, None)), Some(GameLoader::Fabric));
assert_eq!(hit.unwrap().name, "major");
}
}

View file

@ -123,7 +123,7 @@ impl Component for ClusterShell {
let parsed = parse_mc_version(&cluster.mc_version);
let metadata = use_version_metadata(
parsed.as_ref().map(|p| p.major),
parsed.and_then(|p| p.minor),
parsed.and_then(|p| p.key()),
Some(cluster.mc_loader),
);
let subtitle = metadata

View file

@ -5,10 +5,14 @@ use std::sync::OnceLock;
use chrono::{Datelike, NaiveDate};
use oneclient_core::clusters::Cluster;
use oneclient_core::packages::domain::GameLoader;
use oneclient_core::{format_mc_version, parse_mc_version};
use oneclient_core::{VersionKey, format_mc_version, parse_mc_version};
pub type ClusterGroups = BTreeMap<u32, Vec<Cluster>>;
fn cluster_key(cluster: &Cluster) -> Option<VersionKey> {
parse_mc_version(&cluster.mc_version).and_then(|parsed| parsed.key())
}
pub fn group_clusters_by_major(clusters: &[Cluster]) -> ClusterGroups {
let mut groups: ClusterGroups = BTreeMap::new();
@ -23,7 +27,7 @@ pub fn group_clusters_by_major(clusters: &[Cluster]) -> ClusterGroups {
}
for list in groups.values_mut() {
list.sort_by(|a, b| a.mc_version.cmp(&b.mc_version).reverse());
list.sort_by(|a, b| cluster_key(b).cmp(&cluster_key(a)));
}
groups
@ -51,18 +55,17 @@ pub fn loader_tags(clusters: &[Cluster]) -> Vec<String> {
tags
}
pub fn minor_versions(clusters: &[Cluster]) -> Vec<u32> {
let mut minors = Vec::new();
pub fn version_keys(clusters: &[Cluster]) -> Vec<VersionKey> {
let mut keys = Vec::new();
for cluster in clusters {
if let Some(parsed) = parse_mc_version(&cluster.mc_version)
&& let Some(minor) = parsed.minor
&& !minors.contains(&minor)
if let Some(key) = cluster_key(cluster)
&& !keys.contains(&key)
{
minors.push(minor);
keys.push(key);
}
}
minors.sort();
minors
keys.sort();
keys
}
pub fn loaders_for_major(clusters: &[Cluster]) -> Vec<GameLoader> {
@ -78,15 +81,13 @@ pub fn loaders_for_major(clusters: &[Cluster]) -> Vec<GameLoader> {
pub fn resolve_cluster(
clusters: &[Cluster],
minor: Option<u32>,
key: Option<VersionKey>,
loader: Option<GameLoader>,
) -> Option<Cluster> {
clusters
.iter()
.find(|cluster| {
let version_ok = minor.is_none_or(|minor| {
parse_mc_version(&cluster.mc_version).and_then(|parsed| parsed.minor) == Some(minor)
});
let version_ok = key.is_none_or(|key| cluster_key(cluster) == Some(key));
let loader_ok = loader.is_none_or(|loader| cluster.mc_loader == loader);
version_ok && loader_ok
})
@ -102,17 +103,20 @@ pub fn default_major(groups: &ClusterGroups, active: Option<Cluster>) -> Option<
groups.keys().next().copied()
}
pub fn default_minor(clusters: &[Cluster], preferred: Option<u32>) -> Option<u32> {
let minors = minor_versions(clusters);
if minors.is_empty() {
pub fn default_version_key(
clusters: &[Cluster],
preferred: Option<VersionKey>,
) -> Option<VersionKey> {
let keys = version_keys(clusters);
if keys.is_empty() {
return None;
}
if let Some(minor) = preferred
&& minors.contains(&minor)
if let Some(key) = preferred
&& keys.contains(&key)
{
return Some(minor);
return Some(key);
}
minors.last().copied()
keys.last().copied()
}
pub fn default_loader(clusters: &[Cluster], preferred: Option<GameLoader>) -> Option<GameLoader> {
@ -128,8 +132,8 @@ pub fn default_loader(clusters: &[Cluster], preferred: Option<GameLoader>) -> Op
Some(loaders[0])
}
pub fn minor_label(major: u32, minor: u32) -> String {
format_mc_version(major, minor)
pub fn version_label(major: u32, (minor, patch): VersionKey) -> String {
format_mc_version(major, minor, patch)
}
/// Human-readable byte size (B / KB / MB).
@ -236,10 +240,10 @@ fn compare_last_played(a: &Cluster, b: &Cluster) -> Ordering {
}
}
fn version_sort_key(cluster: &Cluster) -> (u32, u32) {
fn version_sort_key(cluster: &Cluster) -> (u32, u32, u32) {
parse_mc_version(&cluster.mc_version)
.map(|v| (v.major, v.minor.unwrap_or(0)))
.unwrap_or((0, 0))
.map(|v| (v.major, v.minor.unwrap_or(0), v.patch.unwrap_or(0)))
.unwrap_or((0, 0, 0))
}
#[cfg(not(target_os = "linux"))]

View file

@ -1,6 +1,7 @@
use freya::prelude::*;
use freya::query::QueryStateData;
use freya::router::RouterContext;
use oneclient_core::VersionKey;
use oneclient_core::clusters::Cluster;
use oneclient_core::packages::domain::GameLoader;
@ -12,8 +13,8 @@ use crate::routes::Route;
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::utils::{
default_loader, default_major, default_minor, group_clusters_by_major, loaders_for_major,
major_pretty_name, minor_label, minor_versions, resolve_cluster,
default_loader, default_major, default_version_key, group_clusters_by_major, loaders_for_major,
major_pretty_name, resolve_cluster, version_keys, version_label,
};
use crate::view::app::launch_button_state;
@ -31,7 +32,7 @@ impl Component for Clusters {
let clusters_query = use_clusters();
let active_id = use_active_cluster_id();
let mut selected_major = use_state(|| None::<u32>);
let mut selected_minor = use_state(|| None::<u32>);
let mut selected_version = use_state(|| None::<VersionKey>);
let mut selected_loader = use_state(|| None::<GameLoader>);
let mut grid_columns = use_state(|| 2_usize);
@ -90,12 +91,12 @@ impl Component for Clusters {
let clusters_for_major = groups.get(&major).cloned().unwrap_or_default();
if selected_minor.read().is_none() {
if selected_version.read().is_none() {
let preferred = active_cluster
.as_ref()
.and_then(|c| oneclient_core::parse_mc_version(&c.mc_version))
.and_then(|p| p.minor);
*selected_minor.write() = default_minor(&clusters_for_major, preferred);
.and_then(|p| p.key());
*selected_version.write() = default_version_key(&clusters_for_major, preferred);
}
if selected_loader.read().is_none() {
@ -105,7 +106,7 @@ impl Component for Clusters {
let cluster = resolve_cluster(
&clusters_for_major,
*selected_minor.read(),
*selected_version.read(),
*selected_loader.read(),
)
.or_else(|| clusters_for_major.first().cloned());
@ -152,12 +153,12 @@ impl Component for Clusters {
let list = groups.get(&major).cloned().unwrap_or_default();
let selected = *selected_major.read() == Some(major);
let mut selected_major = selected_major;
let mut selected_minor = selected_minor;
let mut selected_version = selected_version;
let mut selected_loader = selected_loader;
MajorVersionCard::new(major, &list, selected, move |_| {
*selected_major.write() = Some(major);
*selected_minor.write() = None;
*selected_version.write() = None;
*selected_loader.write() = None;
})
.into_element()
@ -176,7 +177,7 @@ impl Component for Clusters {
major,
cluster,
&clusters_for_major,
selected_minor,
selected_version,
selected_loader,
),
None => sidebar_error(),
@ -189,20 +190,20 @@ fn detail_sidebar(
major: u32,
cluster: Cluster,
clusters_for_major: &[Cluster],
selected_minor: State<Option<u32>>,
selected_version: State<Option<VersionKey>>,
selected_loader: State<Option<GameLoader>>,
) -> Element {
let active_id = use_active_cluster_id();
let dispatch = use_dispatch();
let game = use_game_snapshot();
let minors = minor_versions(clusters_for_major);
let keys = version_keys(clusters_for_major);
let loaders = loaders_for_major(clusters_for_major);
let cluster_id = cluster.id;
let version_title = major_pretty_name(major);
let minor_value = *selected_minor.read();
let version_value = *selected_version.read();
let loader_value = *selected_loader.read();
let metadata = use_version_metadata(Some(major), minor_value, loader_value);
let metadata = use_version_metadata(Some(major), version_value, loader_value);
let heading = metadata
.as_ref()
@ -229,7 +230,7 @@ fn detail_sidebar(
.border(border_all_color(1., colors::component_border()))
.overflow(Overflow::Clip)
.child(rect().width(Size::fill()).max_height(Size::px(140.)).child(
ClusterLandscapeArt::for_version(major, minor_value, loader_value, false),
ClusterLandscapeArt::for_version(major, version_value, loader_value, false),
))
.child(
rect()
@ -259,7 +260,7 @@ fn detail_sidebar(
.font_size(12.)
.color(colors::fg_secondary()),
)
.children(minor_rows(major, &minors, minor_value, selected_minor))
.children(version_rows(major, &keys, version_value, selected_version))
.children(loader_rows(&loaders, loader_value, selected_loader)),
)
.child(
@ -308,28 +309,28 @@ fn tags_row(tags: &[String]) -> Option<Element> {
)
}
fn minor_rows(
fn version_rows(
major: u32,
minors: &[u32],
selected: Option<u32>,
mut selected_minor: State<Option<u32>>,
keys: &[VersionKey],
selected: Option<VersionKey>,
mut selected_version: State<Option<VersionKey>>,
) -> Option<Element> {
if minors.len() <= 1 {
if keys.len() <= 1 {
return None;
}
let options: Vec<String> = minors.iter().map(|m| minor_label(major, *m)).collect();
let options: Vec<String> = keys.iter().map(|k| version_label(major, *k)).collect();
let current = selected
.and_then(|s| minors.iter().position(|m| *m == s))
.and_then(|s| keys.iter().position(|k| *k == s))
.unwrap_or(0);
let minors = minors.to_vec();
let keys = keys.to_vec();
Some(info_row(
"Minor Version",
Dropdown::new(options[current].clone(), options)
.on_select(move |idx: usize| {
if let Some(minor) = minors.get(idx).copied() {
*selected_minor.write() = Some(minor);
if let Some(key) = keys.get(idx).copied() {
*selected_version.write() = Some(key);
}
})
.into_element(),

View file

@ -0,0 +1,272 @@
use std::path::{Path, PathBuf};
use std::str::FromStr;
use oneclient_db::dao::{applied_migration as migration_dao, cluster as cluster_dao};
use oneclient_db::models::ClusterRow;
use crate::LauncherResult;
use crate::packages::domain::GameLoader;
use crate::state::LauncherState;
use crate::versions::RemoteMigration;
#[tracing::instrument(skip(state))]
pub async fn apply_remote_migrations(state: &LauncherState) -> LauncherResult<usize> {
let rules = state.versions.migrations().await;
if rules.is_empty() {
return Ok(0);
}
let mut migrated = 0;
for rule in rules {
match apply_rule(state, &rule).await {
Ok(true) => migrated += 1,
Ok(false) => {}
Err(err) => {
tracing::warn!(
migration_id = %rule.id,
error = %err,
"cluster migration failed; leaving install untouched"
);
}
}
}
if migrated > 0 {
tracing::info!(migrated, "applied cluster migrations");
}
Ok(migrated)
}
async fn apply_rule(state: &LauncherState, rule: &RemoteMigration) -> LauncherResult<bool> {
let db = &state.services.db;
if migration_dao::is_applied(db, &rule.id).await? {
return Ok(false);
}
let Ok(loader) = GameLoader::from_str(&rule.from.loader) else {
tracing::warn!(
migration_id = %rule.id,
loader = %rule.from.loader,
"unknown loader in migration rule; retiring rule"
);
migration_dao::mark_applied(db, &rule.id).await?;
return Ok(false);
};
if rule.from.mc_version == rule.to.mc_version {
tracing::warn!(migration_id = %rule.id, "migration is a no-op; retiring rule");
migration_dao::mark_applied(db, &rule.id).await?;
return Ok(false);
}
let Some(source) =
cluster_dao::find_by_version_loader(db, &rule.from.mc_version, loader as i64).await?
else {
migration_dao::mark_applied(db, &rule.id).await?;
return Ok(false);
};
if cluster_dao::find_by_version_loader(db, &rule.to.mc_version, loader as i64)
.await?
.is_some()
{
tracing::warn!(
migration_id = %rule.id,
from = %rule.from.mc_version,
to = %rule.to.mc_version,
"target cluster already exists; skipping migration"
);
return Ok(false);
}
if crate::game::is_running(state, source.id) {
tracing::info!(
migration_id = %rule.id,
cluster_id = source.id,
"cluster is running; deferring migration"
);
return Ok(false);
}
migrate_cluster(state, rule, &source).await?;
migration_dao::mark_applied(db, &rule.id).await?;
tracing::info!(
migration_id = %rule.id,
cluster_id = source.id,
from = %rule.from.mc_version,
to = %rule.to.mc_version,
"migrated cluster"
);
Ok(true)
}
async fn migrate_cluster(
state: &LauncherState,
rule: &RemoteMigration,
source: &ClusterRow,
) -> LauncherResult<()> {
let from = &rule.from.mc_version;
let to = &rule.to.mc_version;
let clusters_dir = crate::paths::clusters_dir()?;
let old_dir = clusters_dir.join(&source.folder_name);
let new_folder = resolve_new_folder(&clusters_dir, &source.folder_name, from, to).await?;
let new_name = retarget_version_prefix(&source.name, from, to);
let mut renamed: Option<(PathBuf, PathBuf)> = None;
if let Some(folder) = &new_folder {
let new_dir = clusters_dir.join(folder);
if polyio::try_exists(&old_dir).await? {
polyio::rename(&old_dir, &new_dir).await?;
renamed = Some((old_dir.clone(), new_dir));
} else {
tracing::warn!(
folder = %source.folder_name,
"cluster directory is missing on disk; migrating the row only"
);
}
}
let folder_for_db = folder_for_db(
&source.folder_name,
new_folder.as_deref(),
renamed.is_some(),
);
match cluster_dao::migrate_version(
&state.services.db,
source.id,
to,
new_name.as_deref(),
folder_for_db,
)
.await
{
Ok(_) => Ok(()),
Err(err) => {
if let Some((old_dir, new_dir)) = renamed
&& let Err(rollback) = polyio::rename(&new_dir, &old_dir).await
{
tracing::error!(
migration_id = %rule.id,
cluster_id = source.id,
from = ?new_dir,
to = ?old_dir,
error = %rollback,
"failed to roll back cluster directory rename after database error"
);
}
Err(err.into())
}
}
}
fn folder_for_db<'a>(current: &'a str, new_folder: Option<&'a str>, renamed: bool) -> &'a str {
match (renamed, new_folder) {
(true, Some(folder)) => folder,
_ => current,
}
}
async fn resolve_new_folder(
clusters_dir: &Path,
folder_name: &str,
from: &str,
to: &str,
) -> LauncherResult<Option<String>> {
let Some(candidate) = retarget_version_prefix(folder_name, from, to) else {
tracing::debug!(
folder = %folder_name,
"folder name is not in generated form; leaving it in place"
);
return Ok(None);
};
if polyio::try_exists(&clusters_dir.join(&candidate)).await? {
tracing::warn!(
folder = %folder_name,
candidate = %candidate,
"target folder already exists; leaving directory in place"
);
return Ok(None);
}
Ok(Some(candidate))
}
fn retarget_version_prefix(value: &str, from: &str, to: &str) -> Option<String> {
let rest = value.strip_prefix(from)?;
if !rest.starts_with(' ') {
return None;
}
Some(format!("{to}{rest}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retargets_generated_folder() {
assert_eq!(
retarget_version_prefix("26.1 fabric", "26.1", "26.1.2").as_deref(),
Some("26.1.2 fabric")
);
}
#[test]
fn retargets_deduplicated_folder() {
assert_eq!(
retarget_version_prefix("26.1 fabric (1)", "26.1", "26.1.2").as_deref(),
Some("26.1.2 fabric (1)")
);
}
#[test]
fn does_not_match_adjacent_minor() {
assert_eq!(
retarget_version_prefix("26.10 fabric", "26.1", "26.1.2"),
None
);
}
#[test]
fn leaves_custom_names_alone() {
assert_eq!(
retarget_version_prefix("my cool pack", "26.1", "26.1.2"),
None
);
assert_eq!(
retarget_version_prefix("26.1fabric", "26.1", "26.1.2"),
None
);
}
#[test]
fn exact_version_with_no_suffix_is_not_retargeted() {
assert_eq!(retarget_version_prefix("26.1", "26.1", "26.1.2"), None);
}
#[test]
fn row_claims_new_folder_only_when_the_directory_moved() {
assert_eq!(
folder_for_db("26.1 fabric", Some("26.1.2 fabric"), true),
"26.1.2 fabric"
);
}
#[test]
fn row_keeps_its_folder_when_the_rename_was_skipped() {
assert_eq!(
folder_for_db("26.1 fabric", Some("26.1.2 fabric"), false),
"26.1 fabric"
);
assert_eq!(folder_for_db("my cool pack", None, false), "my cool pack");
}
}

View file

@ -1,6 +1,7 @@
mod cluster;
mod error;
mod manager;
mod migrate;
mod options;
mod prepare;
mod provision;
@ -9,6 +10,7 @@ mod stage;
pub use cluster::{Cluster, ClusterLinkTarget};
pub use error::ClusterError;
pub use manager::ClusterManager;
pub use migrate::apply_remote_migrations;
pub use options::{ClusterUpdate, CreateClusterOptions};
pub use prepare::{estimate_cluster_download, prepare_cluster};
pub use provision::{ensure_from_bundles, ensure_from_versions};

View file

@ -90,7 +90,7 @@ pub async fn ensure_from_versions(state: &LauncherState) -> LauncherResult<Vec<C
continue;
};
let mc_version = format_mc_version(entry.major_version, minor);
let mc_version = format_mc_version(entry.major_version, minor, entry.patch_version);
if cluster_dao::find_by_version_loader(&state.services.db, &mc_version, loader as i64)
.await?

View file

@ -75,5 +75,5 @@ pub use patch::Patch;
pub use settings::ProfileUpdate;
pub use state::LauncherServices;
pub use state::LauncherState;
pub use version::{ParsedMcVersion, format_mc_version, parse_mc_version};
pub use versions::{VersionMetadata, VersionsManager, VersionsManifest};
pub use version::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version};
pub use versions::{RemoteMigration, VersionMetadata, VersionsManager, VersionsManifest};

View file

@ -108,6 +108,10 @@ impl LauncherState {
tracing::warn!("bundle tracking restore failed: {err:#}");
}
if let Err(err) = crate::clusters::apply_remote_migrations(&background).await {
tracing::error!("cluster migrations failed: {err:#}");
}
if let Err(err) = crate::clusters::ensure_from_versions(&background).await {
tracing::error!("versions cluster provisioning failed: {err:#}");
} else {

View file

@ -3,8 +3,18 @@
pub struct ParsedMcVersion {
pub major: u32,
pub minor: Option<u32>,
pub patch: Option<u32>,
}
impl ParsedMcVersion {
#[must_use]
pub fn key(&self) -> Option<VersionKey> {
Some((self.minor?, self.patch))
}
}
pub type VersionKey = (u32, Option<u32>);
pub fn parse_mc_version(version: &str) -> Option<ParsedMcVersion> {
let parts: Vec<&str> = version.split('.').collect();
@ -14,9 +24,14 @@ pub fn parse_mc_version(version: &str) -> Option<ParsedMcVersion> {
}
let major = parts[0].parse().ok()?;
let minor = parts[1].parse().ok()?;
let patch = match parts.get(2) {
Some(p) => Some(p.parse().ok()?),
None => None,
};
return Some(ParsedMcVersion {
major,
minor: Some(minor),
patch,
});
}
@ -25,19 +40,26 @@ pub fn parse_mc_version(version: &str) -> Option<ParsedMcVersion> {
}
let major = parts[1].parse().ok()?;
let minor = parts
.get(2)
.and_then(|p| p.parse().ok());
let minor = parts.get(2).and_then(|p| p.parse().ok());
Some(ParsedMcVersion { major, minor })
Some(ParsedMcVersion {
major,
minor,
patch: None,
})
}
#[must_use]
pub fn format_mc_version(major: u32, minor: u32) -> String {
if major >= 26 {
pub fn format_mc_version(major: u32, minor: u32, patch: Option<u32>) -> String {
let base = if major >= 26 {
format!("{major}.{minor}")
} else {
format!("1.{major}.{minor}")
};
match patch {
Some(patch) => format!("{base}.{patch}"),
None => base,
}
}
@ -55,10 +77,20 @@ mod tests {
let v = parse_mc_version("26.1").unwrap();
assert_eq!(v.major, 26);
assert_eq!(v.minor, Some(1));
assert_eq!(v.patch, None);
let v = parse_mc_version("26.2").unwrap();
assert_eq!(v.major, 26);
assert_eq!(v.minor, Some(2));
assert_eq!(v.patch, None);
}
#[test]
fn parse_modern_patch_versions() {
let v = parse_mc_version("26.1.2").unwrap();
assert_eq!(v.major, 26);
assert_eq!(v.minor, Some(1));
assert_eq!(v.patch, Some(2));
}
#[test]
@ -66,11 +98,36 @@ mod tests {
let v = parse_mc_version("1.21.5").unwrap();
assert_eq!(v.major, 21);
assert_eq!(v.minor, Some(5));
assert_eq!(v.patch, None);
}
#[test]
fn reject_garbage_patch_component() {
assert!(parse_mc_version("26.1.x").is_none());
}
#[test]
fn format_versions() {
assert_eq!(format_mc_version(26, 1), "26.1");
assert_eq!(format_mc_version(21, 5), "1.21.5");
assert_eq!(format_mc_version(26, 1, None), "26.1");
assert_eq!(format_mc_version(26, 1, Some(2)), "26.1.2");
assert_eq!(format_mc_version(21, 5, None), "1.21.5");
}
#[test]
fn format_round_trips_through_parse() {
for (major, minor, patch) in [(26, 1, None), (26, 1, Some(2)), (21, 5, None)] {
let text = format_mc_version(major, minor, patch);
let parsed = parse_mc_version(&text).unwrap();
assert_eq!(parsed.major, major);
assert_eq!(parsed.minor, Some(minor));
assert_eq!(parsed.patch, patch);
}
}
#[test]
fn patch_sorts_after_bare_minor() {
let bare = parse_mc_version("26.1").unwrap().key().unwrap();
let patched = parse_mc_version("26.1.2").unwrap().key().unwrap();
assert!(bare < patched);
}
}

View file

@ -7,7 +7,7 @@ use tokio::sync::RwLock;
use crate::api_config::meta_url_base;
use crate::paths;
use crate::state::LauncherServices;
use crate::versions::manifest::{VersionMetadata, VersionsManifest};
use crate::versions::manifest::{RemoteMigration, VersionMetadata, VersionsManifest};
use crate::{LauncherError, LauncherResult};
pub struct VersionsManager {
@ -36,6 +36,10 @@ impl VersionsManager {
self.manifest.read().await.metadata()
}
pub async fn migrations(&self) -> Vec<RemoteMigration> {
self.manifest.read().await.migrations.clone()
}
#[tracing::instrument(level = "debug", skip(services))]
async fn fetch_manifest(
services: &LauncherServices,

View file

@ -1,11 +1,14 @@
use serde::{Deserialize, Serialize};
use crate::api_config::meta_url_base;
use crate::version::VersionKey;
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub struct VersionsManifest {
#[serde(default)]
pub clusters: Vec<RemoteCluster>,
#[serde(default)]
pub migrations: Vec<RemoteMigration>,
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
@ -27,6 +30,8 @@ pub struct RemoteCluster {
pub struct RemoteEntry {
pub minor_version: u32,
#[serde(default)]
pub patch_version: Option<u32>,
#[serde(default)]
pub loader: Option<String>,
#[serde(default)]
pub name: Option<String>,
@ -38,10 +43,29 @@ pub struct RemoteEntry {
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RemoteMigration {
pub id: String,
pub from: MigrationSource,
pub to: MigrationTarget,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MigrationSource {
pub mc_version: String,
pub loader: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MigrationTarget {
pub mc_version: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VersionMetadata {
pub major_version: u32,
pub minor_version: Option<u32>,
pub patch_version: Option<u32>,
pub loader: Option<String>,
pub name: String,
pub art_url: Option<String>,
@ -49,6 +73,13 @@ pub struct VersionMetadata {
pub tags: Vec<String>,
}
impl VersionMetadata {
#[must_use]
pub fn key(&self) -> Option<VersionKey> {
Some((self.minor_version?, self.patch_version))
}
}
fn art_url(path: &Option<String>) -> Option<String> {
path.as_ref()
.map(|p| format!("{}{p}", meta_url_base()))
@ -67,6 +98,7 @@ impl VersionsManifest {
out.push(VersionMetadata {
major_version: cluster.major_version,
minor_version: None,
patch_version: None,
loader: None,
name: cluster_name.clone(),
art_url: art_url(&cluster.art),
@ -78,6 +110,7 @@ impl VersionsManifest {
out.push(VersionMetadata {
major_version: cluster.major_version,
minor_version: Some(entry.minor_version),
patch_version: entry.patch_version,
loader: entry.loader.clone(),
name: entry.name.clone().unwrap_or_else(|| cluster_name.clone()),
art_url: art_url(&entry.art).or_else(|| art_url(&cluster.art)),
@ -93,3 +126,57 @@ impl VersionsManifest {
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_without_migrations_key_parses() {
let manifest: VersionsManifest =
serde_json::from_str(r#"{"clusters": []}"#).expect("should parse");
assert!(manifest.migrations.is_empty());
}
#[test]
fn entry_without_patch_key_parses() {
let manifest: VersionsManifest = serde_json::from_str(
r#"{"clusters":[{"major_version":26,"entries":[{"minor_version":1}]}]}"#,
)
.expect("should parse");
assert_eq!(manifest.clusters[0].entries[0].patch_version, None);
}
#[test]
fn patch_version_reaches_metadata() {
let manifest: VersionsManifest = serde_json::from_str(
r#"{"clusters":[{"major_version":26,"entries":[
{"minor_version":1,"patch_version":2,"loader":"fabric"}
]}]}"#,
)
.expect("should parse");
let metadata = manifest.metadata();
let entry = metadata
.iter()
.find(|m| m.minor_version == Some(1))
.expect("entry present");
assert_eq!(entry.patch_version, Some(2));
assert_eq!(entry.key(), Some((1, Some(2))));
}
#[test]
fn migrations_parse() {
let manifest: VersionsManifest = serde_json::from_str(
r#"{"clusters":[],"migrations":[
{"id":"x","from":{"mc_version":"26.1","loader":"fabric"},
"to":{"mc_version":"26.1.2"}}
]}"#,
)
.expect("should parse");
assert_eq!(manifest.migrations[0].id, "x");
assert_eq!(manifest.migrations[0].from.mc_version, "26.1");
assert_eq!(manifest.migrations[0].to.mc_version, "26.1.2");
}
}

View file

@ -2,4 +2,6 @@ mod manager;
mod manifest;
pub use manager::VersionsManager;
pub use manifest::{VersionMetadata, VersionsManifest};
pub use manifest::{
MigrationSource, MigrationTarget, RemoteMigration, VersionMetadata, VersionsManifest,
};

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT id FROM applied_migrations WHERE id = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "applied_migrations",
"name": "id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "07450861d48b3bff106b7c06e958665f6be44e7937deffb921ddb6a5450805ab"
}

View file

@ -0,0 +1,158 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE clusters\n\t\tSET mc_version = ?,\n\t\t name = ?,\n\t\t folder_name = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "id"
}
}
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "name"
}
}
},
{
"name": "folder_name",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "folder_name"
}
}
},
{
"name": "setting_profile_name",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "setting_profile_name"
}
}
},
{
"name": "mc_version",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_version"
}
}
},
{
"name": "mc_loader",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader"
}
}
},
{
"name": "stage",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "stage"
}
}
},
{
"name": "mc_loader_version",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader_version"
}
}
},
{
"name": "created_at",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "created_at"
}
}
},
{
"name": "last_played",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "last_played"
}
}
},
{
"name": "overall_played",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "overall_played"
}
}
},
{
"name": "linked_modpack_hash",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "linked_modpack_hash"
}
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
false,
false,
false,
true,
false,
false,
false,
true,
true,
true,
true,
true
]
},
"hash": "1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR IGNORE INTO applied_migrations (id, applied_at) VALUES (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "836429d5d4054d1605d7de4cbcf3703ba78d72f39b3090734bb3e69c2b72d50c"
}

View file

@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "\n SELECT 1 FROM cluster_artifacts\n WHERE cluster_id = ? AND bundle_name = ? AND package_id = ?\n LIMIT 1\n ",
"describe": {
"columns": [
{
"name": "1",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false
]
},
"hash": "dc93cdd57e23e48f8e3c333db4ec396b0ee319adec619e5a7d147805573ae2b2"
}

View file

@ -13,6 +13,9 @@ authors.workspace = true
[lib]
path = "src/lib.rs"
[dev-dependencies]
tokio.workspace = true
[dependencies]
sqlx.workspace = true
serde.workspace = true

View file

@ -0,0 +1,4 @@
CREATE TABLE applied_migrations (
id TEXT PRIMARY KEY NOT NULL,
applied_at TEXT NOT NULL
);

View file

@ -0,0 +1,24 @@
use chrono::Utc;
use sqlx::SqlitePool;
pub async fn is_applied(pool: &SqlitePool, id: &str) -> Result<bool, sqlx::Error> {
let row = sqlx::query!("SELECT id FROM applied_migrations WHERE id = ?", id)
.fetch_optional(pool)
.await?;
Ok(row.is_some())
}
pub async fn mark_applied(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> {
let applied_at = Utc::now().to_rfc3339();
sqlx::query!(
"INSERT OR IGNORE INTO applied_migrations (id, applied_at) VALUES (?, ?)",
id,
applied_at
)
.execute(pool)
.await?;
Ok(())
}

View file

@ -149,6 +149,40 @@ pub async fn update(
.await
}
pub async fn migrate_version(
pool: &SqlitePool,
id: i64,
mc_version: &str,
name: Option<&str>,
folder_name: &str,
) -> Result<ClusterRow, sqlx::Error> {
let existing = get_by_id(pool, id)
.await?
.ok_or(sqlx::Error::RowNotFound)?;
let name = name.unwrap_or(&existing.name);
sqlx::query_as!(
ClusterRow,
r#"
UPDATE clusters
SET mc_version = ?,
name = ?,
folder_name = ?
WHERE id = ?
RETURNING
id, name, folder_name, setting_profile_name, mc_version, mc_loader,
stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash
"#,
mc_version,
name,
folder_name,
id
)
.fetch_one(pool)
.await
}
pub async fn set_stage(pool: &SqlitePool, id: i64, stage: i64) -> Result<ClusterRow, sqlx::Error> {
sqlx::query_as!(
ClusterRow,
@ -200,3 +234,102 @@ pub async fn delete_by_id(pool: &SqlitePool, id: i64) -> Result<bool, sqlx::Erro
Ok(result.rows_affected() > 0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dao::applied_migration;
async fn pool() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:")
.await
.expect("in-memory sqlite");
sqlx::migrate!().run(&pool).await.expect("migrations run");
pool
}
async fn seed(pool: &SqlitePool, mc_version: &str, folder: &str) -> ClusterRow {
insert(
pool,
&NewCluster {
name: &format!("{mc_version} fabric"),
folder_name: folder,
mc_version,
mc_loader: 1,
mc_loader_version: None,
setting_profile_name: None,
stage: 0,
},
)
.await
.expect("insert cluster")
}
#[tokio::test]
async fn migrate_version_rewrites_identity_and_keeps_history() {
let pool = pool().await;
let cluster = seed(&pool, "26.1", "26.1 fabric").await;
add_playtime(&pool, cluster.id, 3600)
.await
.expect("record playtime");
let migrated = migrate_version(
&pool,
cluster.id,
"26.1.2",
Some("26.1.2 fabric"),
"26.1.2 fabric",
)
.await
.expect("migrate");
assert_eq!(migrated.id, cluster.id, "must move the row, not replace it");
assert_eq!(migrated.mc_version, "26.1.2");
assert_eq!(migrated.folder_name, "26.1.2 fabric");
assert_eq!(migrated.name, "26.1.2 fabric");
assert_eq!(migrated.overall_played, Some(3600));
assert_eq!(migrated.created_at, cluster.created_at);
assert!(
find_by_version_loader(&pool, "26.1", 1)
.await
.unwrap()
.is_none()
);
assert_eq!(
find_by_version_loader(&pool, "26.1.2", 1)
.await
.unwrap()
.unwrap()
.id,
cluster.id
);
}
#[tokio::test]
async fn migrate_version_can_leave_name_and_folder_untouched() {
let pool = pool().await;
let cluster = seed(&pool, "26.1", "my cool pack").await;
let migrated = migrate_version(&pool, cluster.id, "26.1.2", None, "my cool pack")
.await
.expect("migrate");
assert_eq!(migrated.mc_version, "26.1.2");
assert_eq!(migrated.name, "26.1 fabric", "custom name must survive");
assert_eq!(migrated.folder_name, "my cool pack");
}
#[tokio::test]
async fn applied_migrations_ledger_is_idempotent() {
let pool = pool().await;
assert!(!applied_migration::is_applied(&pool, "rule-a").await.unwrap());
applied_migration::mark_applied(&pool, "rule-a").await.unwrap();
assert!(applied_migration::is_applied(&pool, "rule-a").await.unwrap());
applied_migration::mark_applied(&pool, "rule-a").await.unwrap();
assert!(applied_migration::is_applied(&pool, "rule-a").await.unwrap());
assert!(!applied_migration::is_applied(&pool, "rule-b").await.unwrap());
}
}

View file

@ -1,3 +1,4 @@
pub mod applied_migration;
pub mod artifact;
pub mod bundle;
pub mod cluster;