26.1.2 support
This commit is contained in:
parent
6c7d0a1ca8
commit
d593ecb09f
27 changed files with 967 additions and 86 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -4599,6 +4599,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))]
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
272
packages/oneclient_core/src/clusters/migrate.rs
Normal file
272
packages/oneclient_core/src/clusters/migrate.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
158
packages/oneclient_db/.sqlx/query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json
generated
Normal file
158
packages/oneclient_db/.sqlx/query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json
generated
Normal 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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -13,6 +13,9 @@ authors.workspace = true
|
|||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
|
||||
[dependencies]
|
||||
sqlx.workspace = true
|
||||
serde.workspace = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
CREATE TABLE applied_migrations (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
24
packages/oneclient_db/src/dao/applied_migration.rs
Normal file
24
packages/oneclient_db/src/dao/applied_migration.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod applied_migration;
|
||||
pub mod artifact;
|
||||
pub mod bundle;
|
||||
pub mod cluster;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue