feat: release migration (#851)

* feat: added migration mechanism for versions 26.3 and above

* feat: added checking for mods that the migrating target already has

* feat: added scheduled migrations for up to 30 days

* feat: added button in cluster settings for manual migratting; added migratting disabled mods with status saving

* feat: added checking for show_initial_migration flag in metadata.json

* feat: added fabric -> orinthe migration (for 1.8.9)

* chore: force open from debug page

* chore: change some UI elements

* fix: don't migrate packages whose dependency failed to install

* fix: keep checking the release migration waitlist past a broken cluster

* fix: make Migrate on a debug fake plan a no-op

* perf: install the version the migration plan already fetched

* chore: fix the clippy warnings the release migration added

* refactor: open every release migration prompt through one helper

* chore(lint): cargo fmt the release migration code

* fix: check the release migration waitlist queries at compile time

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
This commit is contained in:
Maciek 2026-09-28 21:06:30 +02:00 • committed by GitHub
parent abb41dcc52
commit 45da20fac2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 4587 additions and 51 deletions

View file

@ -0,0 +1,110 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT\n\t\t\ttarget_cluster_id, provider, project_id, content_type,\n\t\t\tsource_hash, display_name, expires_at, enabled\n\t\tFROM release_migration_waitlist\n\t\t",
"describe": {
"columns": [
{
"name": "target_cluster_id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "release_migration_waitlist",
"name": "target_cluster_id"
}
}
},
{
"name": "provider",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "release_migration_waitlist",
"name": "provider"
}
}
},
{
"name": "project_id",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "release_migration_waitlist",
"name": "project_id"
}
}
},
{
"name": "content_type",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "release_migration_waitlist",
"name": "content_type"
}
}
},
{
"name": "source_hash",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "release_migration_waitlist",
"name": "source_hash"
}
}
},
{
"name": "display_name",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "release_migration_waitlist",
"name": "display_name"
}
}
},
{
"name": "expires_at",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "release_migration_waitlist",
"name": "expires_at"
}
}
},
{
"name": "enabled",
"ordinal": 7,
"type_info": "Integer",
"origin": {
"Table": {
"table": "release_migration_waitlist",
"name": "enabled"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "15dc690e91fe0bc7e1932a5051f8edd855deaeb31e52209ae436680962dea954"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM release_migration_waitlist WHERE target_cluster_id = ? AND provider = ? AND project_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "1e43f93b56bb495e13826c734fe5fb64515cf1d7c28bc6a1bf1fb1645fdba2a1"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO release_migration_waitlist (\n\t\t\ttarget_cluster_id, provider, project_id, content_type,\n\t\t\tsource_hash, display_name, added_at, expires_at, enabled\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT(target_cluster_id, provider, project_id) DO UPDATE SET\n\t\t\tcontent_type = excluded.content_type,\n\t\t\tsource_hash = excluded.source_hash,\n\t\t\tdisplay_name = excluded.display_name,\n\t\t\tadded_at = excluded.added_at,\n\t\t\texpires_at = excluded.expires_at,\n\t\t\tenabled = excluded.enabled\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "e12409b04c8a3eea09a4bfc1014bca6fe5fbc1256b3537ed37195ed20075f944"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM release_migration_waitlist WHERE expires_at <= ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "ff07b78c0e97f1f38a6900092391d22e475471578b5ef73927e5427c2c0abc77"
}

View file

@ -17,6 +17,7 @@ pub struct Dropdown {
leading: Option<Element>,
width: Size,
height: Size,
outlined: bool,
key: DiffKey,
}
@ -29,6 +30,7 @@ impl Dropdown {
leading: None,
width: Size::px(72.),
height: Size::px(24.),
outlined: false,
key: DiffKey::None,
}
}
@ -45,6 +47,11 @@ impl Dropdown {
self
}
pub fn outlined(mut self) -> Self {
self.outlined = true;
self
}
pub fn leading(mut self, leading: impl IntoElement) -> Self {
self.leading = Some(leading.into_element());
self
@ -77,6 +84,7 @@ impl Component for Dropdown {
let leading = self.leading.clone();
let options = self.options.clone();
let on_select = self.on_select.clone();
let outlined = self.outlined;
let is_open = open();
use_overlay_claim_when(is_open);
@ -131,9 +139,13 @@ impl Component for Dropdown {
.horizontal()
.center()
.spacing(4.)
.padding(Gaps::new_symmetric(0., 8.))
.corner_radius(CornerRadius::new_all(6.))
.padding(Gaps::new_symmetric(0., if outlined { 12. } else { 8. }))
.corner_radius(CornerRadius::new_all(if outlined { 8. } else { 6. }))
.background(trigger_bg)
.maybe(outlined, |el| {
el.main_align(Alignment::SpaceBetween)
.border(border_all_color(1., colors::component_border()))
})
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
@ -155,7 +167,7 @@ impl Component for Dropdown {
.child(
label()
.text(selected)
.font_size(12.)
.font_size(if outlined { 13. } else { 12. })
.color(colors::fg_primary()),
)
.child(

View file

@ -35,6 +35,7 @@ mod pagination;
mod player_model;
mod progress_track;
mod recents_row;
mod release_migration_popup;
mod screenshot_viewer;
mod scrollview;
mod segmented_control;
@ -94,6 +95,7 @@ pub use pagination::Pagination;
pub use player_model::PlayerModel;
pub use progress_track::progress_track;
pub use recents_row::RecentsRow;
pub use release_migration_popup::ReleaseMigrationPopup;
pub use screenshot_viewer::{ScreenshotViewer, screenshot_context_menu};
pub(crate) use scrollview::corrected_scroll;
pub use scrollview::{LazySection, ScrollArea, ScrollAreaCtx};

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,8 @@
//! Always `spawn_forever` never `spawn` Freya's `spawn` cancels the task when
//! the calling component unmounts this work is app-scoped not component-scoped
mod release_migration;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
@ -259,6 +261,10 @@ impl Actions {
});
}
pub fn refresh_settings_from_core(&self) {
self.mutate_settings(|_| {});
}
pub fn edit_settings(&self, edit: impl FnOnce(&mut LauncherSettings)) {
let Some(updated) = self.mutate_settings(edit) else {
return;
@ -270,7 +276,11 @@ impl Actions {
}
pub fn set_settings(&self, settings: LauncherSettings) {
let Some(updated) = self.mutate_settings(|s| *s = settings) else {
let Some(updated) = self.mutate_settings(|s| {
let pending = std::mem::take(&mut s.pending_release_migrations);
*s = settings;
s.pending_release_migrations = pending;
}) else {
return;
};
if let Ok(state) = launcher::state() {

View file

@ -0,0 +1,821 @@
use std::collections::HashMap;
use freya::prelude::spawn_forever;
use std::collections::HashSet;
use oneclient_content::packages::release_migration::{
ReleaseMigrationDependency, ReleaseMigrationPackage, ReleaseMigrationPlan,
ReleaseMigrationSkip, SkipReason, add_to_waitlist, apply_release_migration_dependency,
apply_release_migration_package, plan_release_migrations, process_waitlist,
};
use oneclient_content::packages::{
ContentType, GameLoader, PackageBody, ProjectDetail, ProviderId, VersionDetail, VersionFile,
};
use oneclient_core::ReleaseTarget;
use oneclient_core::clusters::{
Cluster, OfferLookup, ReleaseMigrationOffer, manual_migration_offer, record_new_versions,
release_migration_offer,
};
use oneclient_events::Level;
use super::Actions;
use crate::components::IconType;
use crate::launcher;
use crate::notifications::NotificationSpec;
use crate::state::{AppChannel, PromptOrigin, ReleaseMigrationPrompt, ReleasePlanState};
enum SourcePlan {
Offer(ReleaseMigrationPlan),
Nothing,
Unreachable,
}
fn classify(plan: Result<ReleaseMigrationPlan, oneclient_content::ContentError>) -> SourcePlan {
match plan {
Ok(plan) if plan.unreachable => SourcePlan::Unreachable,
Ok(plan) if plan.packages.is_empty() => SourcePlan::Nothing,
Ok(plan) => SourcePlan::Offer(plan),
Err(err) => {
tracing::warn!(error = %err, "release migration plan failed");
SourcePlan::Unreachable
}
}
}
enum SourcePlans {
Ready {
sources: Vec<Cluster>,
plans: HashMap<i64, ReleasePlanState>,
},
Nothing,
Unreachable,
}
async fn plan_sources(
offer: &ReleaseMigrationOffer,
bundles: &oneclient_content::bundles::BundlesManager,
content: &oneclient_content::ContentCtx,
) -> SourcePlans {
let ids: Vec<i64> = offer.sources.iter().map(|source| source.id).collect();
let mut checked: HashMap<i64, SourcePlan> =
plan_release_migrations(&ids, offer.target.id, bundles, content)
.await
.into_iter()
.map(|(source_id, plan)| (source_id, classify(plan)))
.collect();
let mut sources = Vec::new();
let mut plans = HashMap::new();
for source in &offer.sources {
match checked.remove(&source.id) {
Some(SourcePlan::Offer(plan)) => {
plans.insert(source.id, ReleasePlanState::Ready(plan));
sources.push(source.clone());
}
Some(SourcePlan::Nothing) | None => {}
Some(SourcePlan::Unreachable) => return SourcePlans::Unreachable,
}
}
if sources.is_empty() {
SourcePlans::Nothing
} else {
SourcePlans::Ready { sources, plans }
}
}
fn fake_version(name: &str, project_id: &str, size: u64) -> VersionDetail {
VersionDetail {
version_id: format!("fake-version-{project_id}"),
project_id: project_id.to_string(),
name: name.to_string(),
version_number: "1.0.0".to_string(),
changelog: None,
game_versions: Vec::new(),
loaders: vec![GameLoader::Fabric],
published: chrono::Utc::now(),
downloads: 0,
files: vec![VersionFile {
sha1: format!("fake-{project_id}"),
url: String::new(),
file_name: format!("{project_id}.jar"),
primary: true,
size,
fingerprint: None,
}],
dependencies: Vec::new(),
}
}
fn fake_package(
name: &str,
project_id: &str,
content_type: ContentType,
enabled: bool,
) -> ReleaseMigrationPackage {
ReleaseMigrationPackage {
source_hash: format!("fake-{project_id}"),
enabled,
provider: ProviderId::Modrinth,
project_id: project_id.to_string(),
content_type,
display_name: name.to_string(),
version: fake_version(name, project_id, 2_400_000),
version_name: "1.0.0".to_string(),
size: 2_400_000,
}
}
fn fake_skip(name: &str, project_id: &str, reason: SkipReason) -> ReleaseMigrationSkip {
ReleaseMigrationSkip {
source_hash: format!("fake-{project_id}"),
enabled: true,
provider: ProviderId::Modrinth,
project_id: project_id.to_string(),
content_type: ContentType::Mod,
display_name: name.to_string(),
reason,
}
}
fn fake_dependency(
name: &str,
project_id: &str,
required_by: Vec<String>,
) -> ReleaseMigrationDependency {
let now = chrono::Utc::now();
ReleaseMigrationDependency {
provider: ProviderId::Modrinth,
project: ProjectDetail {
id: project_id.to_string(),
slug: project_id.to_string(),
provider: ProviderId::Modrinth,
content_type: ContentType::Mod,
name: name.to_string(),
summary: "Simulated dependency".to_string(),
author: "nobody".to_string(),
members: Vec::new(),
gallery: Vec::new(),
body: PackageBody::Raw(String::new()),
license: None,
links: Vec::new(),
version_ids: Vec::new(),
game_versions: Vec::new(),
loaders: vec![GameLoader::Fabric],
icon_url: None,
created: now,
updated: now,
downloads: 0,
},
version: fake_version(name, project_id, 900_000),
required_by,
}
}
fn fake_plan(source_cluster_id: i64, target_cluster_id: i64) -> ReleaseMigrationPlan {
let packages = vec![
fake_package("Sodium", "AANobbMI", ContentType::Mod, true),
fake_package(
"Just Enough Items (JEI)",
"u6dRKJwZ",
ContentType::Mod,
true,
),
fake_package("Mod Menu", "mOgUt4GM", ContentType::Mod, false),
fake_package(
"Fresh Animations",
"50dA9Sha",
ContentType::ResourcePack,
true,
),
fake_package(
"Complementary Shaders",
"R6NEzAwj",
ContentType::Shader,
true,
),
];
ReleaseMigrationPlan {
source_cluster_id,
target_cluster_id,
dependencies: vec![fake_dependency(
"Fabric API",
"P7dR8mSH",
vec!["fake-AANobbMI".to_string(), "fake-u6dRKJwZ".to_string()],
)],
unavailable: vec![
fake_skip("Iris Shaders", "YL57xq9U", SkipReason::NoCompatibleVersion),
fake_skip(
"Distant Horizons",
"uCdwusMi",
SkipReason::MissingDependency,
),
],
packages,
unreachable: false,
}
}
impl Actions {
fn write_release_migration(&self, edit: impl FnOnce(&mut Option<ReleaseMigrationPrompt>)) {
let mut guard = self
.station
.clone()
.write_channel(AppChannel::ReleaseMigration);
edit(&mut guard.release_migration);
}
fn remove_pending_release_migrations(&self, keys: Vec<String>) {
if keys.is_empty() {
return;
}
self.edit_settings(move |settings| {
settings
.pending_release_migrations
.retain(|key| !keys.contains(key));
});
}
async fn open_release_prompt(
&self,
state: &std::sync::Arc<oneclient_core::LauncherState>,
offer: ReleaseMigrationOffer,
sources: Vec<Cluster>,
plans: HashMap<i64, ReleasePlanState>,
origin: PromptOrigin,
) {
let java_major = oneclient_core::required_java_major(state, offer.target.id)
.await
.ok()
.flatten();
let selected = sources[0].id;
self.write_release_migration(move |prompt| {
*prompt = Some(ReleaseMigrationPrompt {
key: offer.release.key(),
target: offer.target,
java_major,
sources,
selected,
plans,
origin,
});
});
}
pub fn check_release_migration(&self) {
let actions = self.clone();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
if let Err(err) = record_new_versions(&state).await {
tracing::warn!(error = %err, "could not record new versions for migration");
}
actions.refresh_settings_from_core();
let (pending, onboarded) = {
let settings = state.settings.read();
(
settings.pending_release_migrations.clone(),
settings.seen_onboarding,
)
};
if pending.is_empty() {
return;
}
if !onboarded {
actions.remove_pending_release_migrations(pending);
return;
}
let content = state.services.content();
let mut finished = Vec::new();
for key in pending {
let Some(release) = ReleaseTarget::from_key(&key) else {
finished.push(key);
continue;
};
if !state.versions.shows_initial_migration(&release).await {
tracing::info!(
key,
"pending version opts out of the initial migration offer, dropping it"
);
finished.push(key);
continue;
}
let offer = match release_migration_offer(&state, &release).await {
Ok(OfferLookup::Offer(offer)) => offer,
Ok(OfferLookup::NoSources) => {
finished.push(key);
continue;
}
Ok(OfferLookup::MissingCluster) => continue,
Err(err) => {
tracing::warn!(error = %err, key, "release migration lookup failed");
continue;
}
};
let (sources, plans) =
match plan_sources(&offer, state.bundles.as_ref(), &content).await {
SourcePlans::Ready { sources, plans } => (sources, plans),
SourcePlans::Nothing => {
finished.push(key);
continue;
}
SourcePlans::Unreachable => continue,
};
actions.remove_pending_release_migrations(finished);
actions
.open_release_prompt(&state, *offer, sources, plans, PromptOrigin::NewRelease)
.await;
return;
}
actions.remove_pending_release_migrations(finished);
});
}
pub fn select_release_migration_source(&self, source_id: i64) {
let mut target_id = None;
self.write_release_migration(|prompt| {
let Some(prompt) = prompt.as_mut() else {
return;
};
prompt.selected = source_id;
if let std::collections::hash_map::Entry::Vacant(entry) = prompt.plans.entry(source_id)
{
entry.insert(ReleasePlanState::Loading);
target_id = Some(prompt.target.id);
}
});
let Some(target_id) = target_id else { return };
let actions = self.clone();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let result = plan_release_migrations(
&[source_id],
target_id,
state.bundles.as_ref(),
&state.services.content(),
)
.await
.pop()
.map(|(_, plan)| plan);
let next = match result {
Some(Ok(plan)) if !plan.unreachable => ReleasePlanState::Ready(plan),
Some(Ok(_)) | None => ReleasePlanState::Failed,
Some(Err(err)) => {
tracing::warn!(error = %err, source_id, "release migration plan failed");
ReleasePlanState::Failed
}
};
actions.write_release_migration(move |prompt| {
if let Some(prompt) = prompt.as_mut() {
prompt.plans.insert(source_id, next);
}
});
});
}
/// Debug only: opens the popup on a hand-built plan, so the UI can be
/// reviewed without a cluster that has migratable packages
pub fn simulate_fake_release_migration(&self, target_cluster_id: i64) {
let actions = self.clone();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let Ok(clusters) = state.clusters.list().await else {
return;
};
let Some(target) = clusters
.iter()
.find(|cluster| cluster.id == target_cluster_id)
.cloned()
else {
return;
};
let source = clusters
.into_iter()
.find(|cluster| cluster.id != target.id)
.unwrap_or_else(|| target.clone());
let selected = source.id;
let release = ReleaseTarget {
mc_version: target.mc_version.clone(),
loader: target.mc_loader,
};
let plan = fake_plan(selected, target.id);
actions.write_release_migration(move |prompt| {
*prompt = Some(ReleaseMigrationPrompt {
key: release.key(),
target,
java_major: None,
sources: vec![source],
selected,
plans: HashMap::from([(selected, ReleasePlanState::Ready(plan))]),
origin: PromptOrigin::Fake,
});
});
});
}
pub fn simulate_release_migration(&self, target_cluster_id: i64) {
let actions = self.clone();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let target = match state.clusters.get(target_cluster_id).await {
Ok(target) => target,
Err(err) => {
actions
.notify("Release migration simulation failed")
.body(err.to_string())
.level(Level::Error)
.send();
return;
}
};
let release = ReleaseTarget {
mc_version: target.mc_version.clone(),
loader: target.mc_loader,
};
let offer = match release_migration_offer(&state, &release).await {
Ok(OfferLookup::Offer(offer)) => offer,
Ok(OfferLookup::NoSources | OfferLookup::MissingCluster) => {
actions
.notify("Nothing to migrate")
.body("All found packages were migrated")
.level(Level::Error)
.send();
return;
}
Err(err) => {
actions
.notify("Release migration simulation failed")
.body(err.to_string())
.level(Level::Error)
.send();
return;
}
};
let (sources, plans) =
match plan_sources(&offer, state.bundles.as_ref(), &state.services.content()).await
{
SourcePlans::Ready { sources, plans } => (sources, plans),
SourcePlans::Nothing => {
actions
.notify("Nothing to migrate")
.body("All found packages were migrated")
.level(Level::Error)
.send();
return;
}
SourcePlans::Unreachable => {
actions
.notify("Release migration simulation failed")
.body("Couldn't reach Modrinth or CurseForge.")
.level(Level::Error)
.send();
return;
}
};
actions
.open_release_prompt(&state, *offer, sources, plans, PromptOrigin::Simulated)
.await;
});
}
fn set_release_migration_checking(&self, cluster_id: i64, checking: bool) {
let mut guard = self
.station
.clone()
.write_channel(AppChannel::ReleaseMigration);
if checking {
guard.release_migration_checking.insert(cluster_id);
} else {
guard.release_migration_checking.remove(&cluster_id);
}
}
pub fn open_manual_migration(&self, target_cluster_id: i64, source_cluster_id: i64) {
self.set_release_migration_checking(target_cluster_id, true);
let actions = self.clone();
spawn_forever(async move {
actions
.run_manual_migration(target_cluster_id, source_cluster_id)
.await;
actions.set_release_migration_checking(target_cluster_id, false);
});
}
async fn run_manual_migration(&self, target_cluster_id: i64, source_cluster_id: i64) {
let Ok(state) = launcher::state() else { return };
let nothing = || {
self.notify("Nothing to migrate")
.body("No packages to migrate")
.send();
};
let offer = match manual_migration_offer(&state, target_cluster_id, source_cluster_id).await
{
Ok(OfferLookup::Offer(offer)) => offer,
Ok(OfferLookup::NoSources) => {
nothing();
return;
}
Ok(OfferLookup::MissingCluster) => return,
Err(err) => {
tracing::warn!(error = %err, target_cluster_id, "manual migration lookup failed");
self.notify("Couldn't check for packages to migrate")
.body(err.to_string())
.level(Level::Error)
.send();
return;
}
};
let (sources, plans) = match plan_sources(
&offer,
state.bundles.as_ref(),
&state.services.content(),
)
.await
{
SourcePlans::Ready { sources, plans } => (sources, plans),
SourcePlans::Nothing => {
nothing();
return;
}
SourcePlans::Unreachable => {
self.notify("Couldn't check for packages to migrate")
.body("Couldn't reach Modrinth or CurseForge. Check your connection and try again.")
.level(Level::Error)
.send();
return;
}
};
self.open_release_prompt(&state, *offer, sources, plans, PromptOrigin::Manual)
.await;
}
pub fn queue_release_migration(&self, target_cluster_id: i64) {
let actions = self.clone();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let Ok(target) = state.clusters.get(target_cluster_id).await else {
return;
};
let key = ReleaseTarget {
mc_version: target.mc_version,
loader: target.mc_loader,
}
.key();
actions.write_release_migration(|prompt| *prompt = None);
actions.edit_settings(move |settings| {
if !settings.pending_release_migrations.contains(&key) {
settings.pending_release_migrations.push(key);
}
});
actions.check_release_migration();
});
}
pub fn process_release_waitlist(&self) {
let actions = self.clone();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let content = state.services.content();
let installs = match process_waitlist(
state.bundles.as_ref(),
|cluster_id| oneclient_core::game::is_running(&state, cluster_id),
&content,
)
.await
{
Ok(installs) => installs,
Err(err) => {
tracing::warn!(error = %err, "release migration waitlist check failed");
return;
}
};
if installs.is_empty() {
return;
}
for install in &installs {
let verb = if install.names.len() == 1 {
"supports"
} else {
"support"
};
actions
.notify(format!("New {} builds added", install.mc_version))
.body(format!(
"{} now {verb} {} and {} added to {}.",
install.names.join(", "),
install.mc_version,
if install.names.len() == 1 {
"was"
} else {
"were"
},
install.cluster_name
))
.send();
}
super::super::invalidate_cluster_queries().await;
});
}
pub fn dismiss_release_migration(&self) {
let mut key = None;
self.write_release_migration(|prompt| {
key = prompt
.take()
.filter(|prompt| prompt.origin == PromptOrigin::NewRelease)
.map(|prompt| prompt.key);
});
if let Some(key) = key {
self.remove_pending_release_migrations(vec![key]);
}
}
pub fn migrate_release_packages(&self, packages: Vec<ReleaseMigrationPackage>) {
let mut taken = None;
self.write_release_migration(|prompt| taken = prompt.take());
let Some(prompt) = taken else { return };
match prompt.origin {
PromptOrigin::Fake => {
self.notify("Fake plan")
.body("Nothing was installed")
.send();
return;
}
PromptOrigin::Simulated => {}
PromptOrigin::NewRelease | PromptOrigin::Manual => {
self.remove_pending_release_migrations(vec![prompt.key.clone()]);
}
}
if packages.is_empty() {
return;
}
let chosen: HashSet<String> = packages
.iter()
.map(|package| package.source_hash.clone())
.collect();
let (dependencies, unavailable): (Vec<_>, Vec<_>) = match prompt.plans.get(&prompt.selected)
{
Some(ReleasePlanState::Ready(plan)) => (
plan.dependencies
.iter()
.filter(|dependency| dependency.is_needed_by(&chosen))
.map(|dependency| (dependency.clone(), dependency.enabled_for(&packages)))
.collect(),
plan.unavailable
.iter()
.filter(|skip| skip.reason == SkipReason::NoCompatibleVersion)
.cloned()
.collect(),
),
_ => (Vec::new(), Vec::new()),
};
let waitlisted = !prompt.is_cross_loader();
let actions = self.clone();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let content = state.services.content();
let target = prompt.target;
let total = packages.len() + dependencies.len();
let session = oneclient_events::GroupedProgressSession::start(
&state.services.events,
format!("Migrating to {}", target.name),
);
session.expect(
oneclient_events::TaskCategory::Packages,
total as u64,
total as u64,
);
let mut migrated = 0usize;
let mut blocked: HashSet<String> = HashSet::new();
for (dependency, enabled) in &dependencies {
let child = session.child(
dependency.project.name.clone(),
1,
oneclient_events::TaskCategory::Packages,
);
match apply_release_migration_dependency(
target.id,
dependency,
*enabled,
Some(&child),
&content,
)
.await
{
Ok(_) => migrated += 1,
Err(err) => {
tracing::warn!(
dependency = %dependency.project.name,
error = %err,
"release migration failed for dependency"
);
blocked.extend(dependency.required_by.iter().cloned());
}
}
child.finish();
}
for package in &packages {
let child = session.child(
package.display_name.clone(),
1,
oneclient_events::TaskCategory::Packages,
);
if blocked.contains(&package.source_hash) {
tracing::warn!(
package = %package.display_name,
"skipping release migration for a package whose dependency failed"
);
child.finish();
continue;
}
match apply_release_migration_package(target.id, package, Some(&child), &content)
.await
{
Ok(_) => migrated += 1,
Err(err) => tracing::warn!(
package = %package.display_name,
error = %err,
"release migration failed for package"
),
}
child.finish();
}
if !waitlisted {
tracing::info!(
target_cluster_id = target.id,
skipped = unavailable.len(),
"cross-loader migration, not adding packages without a build to the waitlist"
);
} else if let Err(err) = add_to_waitlist(target.id, &unavailable, &content).await {
tracing::warn!(error = %err, "could not add packages without a build to the migration waitlist");
}
let failed = total - migrated;
let session_id = session.detach();
let spec = Some(NotificationSpec {
title: format!("Migrated to {}", target.name),
body: if failed == 0 {
format!(
"{migrated} package{} copied into {}",
if migrated == 1 { "" } else { "s" },
target.name
)
} else {
format!("{migrated} of {total} packages copied, {failed} failed")
},
level: if failed == 0 {
Level::Info
} else {
Level::Error
},
icon: Some(IconType::DownloadCloud02),
progress: None,
actions: Vec::new(),
toast_only: false,
});
actions.with_engine(|app| {
app.notifications
.finish_grouped_as_actions(&mut app.inbox, session_id, spec);
});
super::super::invalidate_cluster_queries().await;
});
}
}

View file

@ -71,8 +71,8 @@ pub use queries::{
use crate::notifications::NotificationSnapshot;
use crate::state::{
AppChannel, GameState, InstallState, LauncherInit, LoginProgress, RelocationState,
SettingsState, StorageScanProgress,
AppChannel, GameState, InstallState, LauncherInit, LoginProgress, ReleaseMigrationPrompt,
RelocationState, SettingsState, StorageScanProgress,
};
use freya::prelude::*;
use freya::radio::use_radio;
@ -92,6 +92,20 @@ pub fn use_launcher() -> LauncherInit {
use_radio(AppChannel::Launcher).read().launcher.clone()
}
pub fn use_release_migration_checking(cluster_id: i64) -> bool {
use_radio(AppChannel::ReleaseMigration)
.read()
.release_migration_checking
.contains(&cluster_id)
}
pub fn use_release_migration() -> Option<ReleaseMigrationPrompt> {
use_radio(AppChannel::ReleaseMigration)
.read()
.release_migration
.clone()
}
pub fn use_relocation() -> RelocationState {
use_radio(AppChannel::Relocation).read().relocation.clone()
}

View file

@ -4,8 +4,8 @@ use freya::router::*;
use crate::components::{
AccountSwitcher, ClusterUpdatePopup, ControlCenter, FlaggedInstallPopup, GenericPromptOverlay,
JavaPromptOverlay, MicrosoftJavaPromptOverlay, NotificationCenter, OptionalModsPopup,
PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, TooltipHost, UpdatePromptOverlay,
use_provide_tooltips,
PackageUpdatePopup, ReleaseMigrationPopup, SplashCurtain, StatusBar, Toasts, TooltipHost,
UpdatePromptOverlay, use_provide_tooltips,
};
#[cfg(not(target_os = "macos"))]
use crate::hooks::use_start_maximized;
@ -54,6 +54,7 @@ impl Component for RootLayout {
.child(OptionalModsPopup)
.child(PackageUpdatePopup)
.child(FlaggedInstallPopup)
.child(ReleaseMigrationPopup)
.child(StatusBar)
.child(TooltipHost)
.child(SplashCurtain)

View file

@ -6,6 +6,8 @@ use std::sync::Arc;
use freya::radio::RadioChannel;
use oneclient_common::domain::ProviderId;
use oneclient_content::packages::release_migration::ReleaseMigrationPlan;
use oneclient_core::clusters::Cluster;
use oneclient_core::relocate::{RelocationOutcome, RelocationPlan};
use oneclient_core::settings::LauncherSettings;
use oneclient_events::LaunchStage;
@ -26,6 +28,7 @@ pub enum AppChannel {
StorageScan,
Relocation,
PendingLaunch,
ReleaseMigration,
}
impl RadioChannel<AppState> for AppChannel {}
@ -49,6 +52,54 @@ pub struct AppState {
pub storage_scan: Option<StorageScanProgress>,
pub relocation: RelocationState,
pub pending_launch: Option<String>,
pub release_migration: Option<ReleaseMigrationPrompt>,
pub release_migration_checking: HashSet<i64>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PromptOrigin {
NewRelease,
Manual,
Simulated,
Fake,
}
#[derive(Clone, Debug)]
pub enum ReleasePlanState {
Loading,
Ready(ReleaseMigrationPlan),
Failed,
}
#[derive(Clone, Debug)]
pub struct ReleaseMigrationPrompt {
pub key: String,
pub target: Cluster,
pub java_major: Option<u32>,
pub sources: Vec<Cluster>,
pub selected: i64,
pub plans: HashMap<i64, ReleasePlanState>,
pub origin: PromptOrigin,
}
impl ReleaseMigrationPrompt {
#[must_use]
pub fn source(&self) -> Option<&Cluster> {
self.sources
.iter()
.find(|cluster| cluster.id == self.selected)
}
#[must_use]
pub fn plan(&self) -> Option<&ReleasePlanState> {
self.plans.get(&self.selected)
}
#[must_use]
pub fn is_cross_loader(&self) -> bool {
self.source()
.is_some_and(|source| source.mc_loader != self.target.mc_loader)
}
}
/// A move of the data folder owns the whole window while it runs: the router

View file

@ -10,13 +10,15 @@ use crate::components::{
};
use crate::hooks::{
ClusterAction, java_runtimes, loader_versions, mutation_is_running, query_error,
try_game_profile, use_cluster_mutation, use_dispatch, use_game_profile, use_java_runtimes,
use_loader_versions, use_settings_snapshot,
settled_or_loading, try_game_profile, use_cluster_mutation, use_clusters, use_dispatch,
use_game_profile, use_game_snapshot, use_java_runtimes, use_loader_versions,
use_release_migration_checking, use_settings_snapshot,
};
use crate::layout::cluster_content;
use crate::theme::colors;
use crate::ui::centered_note;
use crate::view::app::settings::{section_header, settings_row};
use oneclient_core::clusters::rank_migration_sources;
use super::cluster_not_found;
use crate::hooks::use_cluster;
@ -140,6 +142,7 @@ impl Component for ClusterSettings {
}
.into_element(),
)
.child(MigrateFromRow { cluster_id }.into_element())
.child(section_header("REPAIR"))
.child(VerifyFilesRow { cluster_id }.into_element()),
)
@ -759,6 +762,91 @@ impl Component for BrowserUpdateModeRow {
}
}
#[derive(PartialEq)]
struct MigrateFromRow {
cluster_id: i64,
}
impl Component for MigrateFromRow {
fn render(&self) -> impl IntoElement {
let cluster_id = self.cluster_id;
let dispatch = use_dispatch();
let checking = use_release_migration_checking(cluster_id);
let running = use_game_snapshot().is_running(cluster_id);
let clusters = settled_or_loading(&use_clusters()).unwrap_or_default();
let mut picked = use_state(|| None::<i64>);
let sources = clusters
.iter()
.find(|cluster| cluster.id == cluster_id)
.map(|target| rank_migration_sources(target, &clusters))
.unwrap_or_default();
let selected = picked
.read()
.and_then(|id| sources.iter().find(|source| source.id == id))
.or_else(|| sources.first())
.cloned();
let selected_id = selected.as_ref().map(|source| source.id);
let mut button = Button::new()
.secondary()
.enabled(selected_id.is_some() && !checking && !running)
.on_press(move |_| {
if let Some(source_id) = selected_id {
dispatch.open_manual_migration(cluster_id, source_id);
}
});
button = if checking {
button
.child(Icon::new(IconType::Loading02).size(14.))
.text("Checking…")
} else {
button
.child(Icon::new(IconType::Copy01).size(14.))
.text("Migrate...")
};
if running {
button = button.tooltip("Close the game before migrating");
}
let picker: Element = if sources.is_empty() {
label()
.text("No other clusters with this loader")
.font_size(12.)
.color(colors::fg_secondary())
.into_element()
} else {
let ids: Vec<i64> = sources.iter().map(|source| source.id).collect();
let names: Vec<String> = sources.iter().map(|source| source.name.clone()).collect();
Dropdown::new(
selected.map(|source| source.name).unwrap_or_default(),
names,
)
.width(Size::px(180.))
.height(Size::px(34.))
.on_select(move |index: usize| {
if let Some(id) = ids.get(index) {
picked.set(Some(*id));
}
})
.into_element()
};
settings_row(
IconType::Copy01,
"Migrate",
"Bring mods, resource packs and shaders you installed in another cluster. Compatible versions are downloaded; the other cluster is left as it is.",
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(8.)
.child(picker)
.child(button),
)
}
}
#[derive(PartialEq)]
struct LoaderRow {
cluster_id: i64,

View file

@ -77,6 +77,11 @@ impl Component for Debug {
vec![ClusterUpdateSimulator.into_element()],
))
.child(divider())
.child(section(
"Release Migration",
vec![ReleaseMigrationSimulator.into_element()],
))
.child(divider())
.child(section(
"Launcher Auto Update",
vec![LauncherUpdateSimulator.into_element()],
@ -580,6 +585,124 @@ fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec<ClusterUpdateSu
dispatch.open_optional_mods(optional, None);
}
#[derive(PartialEq)]
struct ReleaseMigrationSimulator;
fn newest_first(clusters: &mut [oneclient_core::clusters::Cluster]) {
clusters.sort_by_key(|cluster| {
std::cmp::Reverse(
oneclient_common::version::parse_mc_version(&cluster.mc_version).map(|parsed| {
(
parsed.major,
parsed.minor.unwrap_or(0),
parsed.patch.unwrap_or(0),
)
}),
)
});
}
impl Component for ReleaseMigrationSimulator {
fn render(&self) -> impl IntoElement {
let dispatch = use_dispatch();
let clusters_query = use_clusters();
let mut target = use_state(|| None::<i64>);
let mut clusters = settled_or_loading(&clusters_query).unwrap_or_default();
newest_first(&mut clusters);
let selected = target
.read()
.and_then(|id| clusters.iter().find(|cluster| cluster.id == id))
.or_else(|| clusters.first())
.cloned();
let ids: Vec<i64> = clusters.iter().map(|cluster| cluster.id).collect();
let options: Vec<String> = clusters
.iter()
.map(|cluster| cluster.name.clone())
.collect();
let selected_name = selected
.as_ref()
.map(|cluster| cluster.name.clone())
.unwrap_or_else(|| "No clusters".to_string());
let selected_id = selected.as_ref().map(|cluster| cluster.id);
let simulate = dispatch.clone();
let fake = dispatch.clone();
let queue = dispatch.clone();
rect()
.vertical()
.width(Size::fill())
.spacing(10.)
.child(
label()
.text("\"Simulate\" opens the real migration modal with the chosen cluster as the new release and the real compatibility check against every other cluster with the same loader; closing it changes nothing. \"Fake Plan\" skips the check and fills the same modal with made-up packages, so the UI can be reviewed when no cluster has anything to migrate. \"Queue as New Version\" adds the chosen version to the pending list exactly as a new manifest entry would, then runs the startup check, so the real one-time flow can be tested.")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(12.)
.child(
label()
.text("Target")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
Dropdown::new(selected_name, options)
.width(Size::px(220.))
.height(Size::px(32.))
.on_select(move |index: usize| {
if let Some(id) = ids.get(index) {
target.set(Some(*id));
}
}),
)
.child(
Button::new()
.primary()
.enabled(selected_id.is_some())
.child(Icon::new(IconType::RefreshCw01).size(16.))
.text("Simulate Release Migration")
.on_press(move |_| {
if let Some(id) = selected_id {
simulate.simulate_release_migration(id);
}
}),
)
.child(
Button::new()
.secondary()
.enabled(selected_id.is_some())
.text("Fake Plan")
.on_press(move |_| {
if let Some(id) = selected_id {
fake.simulate_fake_release_migration(id);
}
}),
)
.child(
Button::new()
.secondary()
.enabled(selected_id.is_some())
.text("Queue as New Version")
.on_press(move |_| {
if let Some(id) = selected_id {
queue.queue_release_migration(id);
}
}),
),
)
.into_element()
}
}
#[derive(PartialEq)]
struct LauncherUpdateSimulator;

View file

@ -645,6 +645,40 @@ pub async fn list_cluster_bundle_overrides(
.collect())
}
#[tracing::instrument(level = "debug", skip(bundles, ctx))]
pub async fn enabled_bundle_projects(
cluster_id: i64,
bundles: &BundlesManager,
ctx: &ContentCtx,
) -> ContentResult<std::collections::HashSet<String>> {
let cluster = PackageStore::get_cluster(cluster_id, ctx).await?;
let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| {
ContentError::InvalidData {
reason: format!("unknown loader {}", cluster.mc_loader),
}
})?;
let archives = bundles
.archives_for(ctx, &cluster.mc_version, loader)
.await?;
let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?;
let mut projects = std::collections::HashSet::new();
for archive in &archives {
let bundle_name = &archive.manifest.name;
for file in &archive.manifest.files {
let BundleFileKind::Managed { project_id, .. } = &file.kind else {
continue;
};
if effective_enabled(file, find_override(&overrides, bundle_name, project_id)) {
projects.insert(project_id.clone());
}
}
}
Ok(projects)
}
#[tracing::instrument(skip(bundles, progress, ctx))]
pub async fn install_cluster_bundles(
cluster_id: i64,

View file

@ -11,12 +11,12 @@ mod updates;
pub use error::BundleError;
pub use install::{
clusters_sharing_artifact, delete_artifact, effective_enabled, enabled_bundle_bytes,
extract_bundle_overrides_for_cluster, heal_bundle_activity, install_bundle,
install_cluster_bundles, install_enabled_bundle_files, install_package_from_bundle,
list_cluster_bundle_overrides, on_user_disable_artifact, on_user_enable_artifact,
on_user_remove_artifact, remove_artifact_from_cluster, set_artifact_enabled_to,
set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override,
set_bundle_package_overrides,
enabled_bundle_projects, extract_bundle_overrides_for_cluster, heal_bundle_activity,
install_bundle, install_cluster_bundles, install_enabled_bundle_files,
install_package_from_bundle, list_cluster_bundle_overrides, on_user_disable_artifact,
on_user_enable_artifact, on_user_remove_artifact, remove_artifact_from_cluster,
set_artifact_enabled_to, set_bundle_package_enabled, set_bundle_package_opt_in,
set_bundle_package_override, set_bundle_package_overrides,
};
pub use manager::{Bundle, BundlesManager};
pub use manifest::BundleManifest as RemoteBundleManifest;

View file

@ -116,7 +116,7 @@ fn dependency_label(dep: &VersionDependency) -> String {
.unwrap_or_else(|| "unknown".to_string())
}
async fn resolve_one(
pub(crate) async fn resolve_one(
provider: &dyn PackageProvider,
dep: &VersionDependency,
cluster: &ClusterRow,

View file

@ -6,6 +6,7 @@ pub mod local_manifest;
pub mod metadata_cache;
pub mod modpack;
pub mod provider;
pub mod release_migration;
pub mod store;
pub mod types;
pub mod updates;

View file

@ -9,11 +9,12 @@ use super::PackageProvider;
use super::http::fetch_json;
use crate::ctx::ContentCtx;
use crate::error::ContentResult;
use crate::packages::dependencies::base_game_version;
use crate::packages::file_identity::FileIdentity;
use crate::packages::types::{
DependencyKind, GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary,
ReleaseType, SearchFilters, VersionDependency, VersionDetail, VersionFile, VersionLookup,
VersionSummary,
DependencyKind, GalleryImage, InstalledPackage, PackageBody, Page, ProjectDetail,
ProjectMember, ProjectSummary, ReleaseType, SearchFilters, VersionDependency, VersionDetail,
VersionFile, VersionLookup, VersionSummary,
};
use oneclient_common::constants::{CURSEFORGE_API_URL, CURSEFORGE_GAME_ID};
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
@ -276,6 +277,123 @@ impl PackageProvider for CurseForgeProvider {
Ok(out)
}
#[tracing::instrument(level = "debug", skip(self, packages, ctx))]
async fn latest_for_game_version(
&self,
packages: &[InstalledPackage],
mc_version: &str,
loader: GameLoader,
ctx: &ContentCtx,
) -> ContentResult<HashMap<String, VersionDetail>> {
let mod_ids: Vec<u32> = packages
.iter()
.filter_map(|package| package.project_id.parse().ok())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
if mod_ids.is_empty() {
return Ok(HashMap::new());
}
let response: CfData<Vec<CfModFiles>> = fetch_json(
&ctx.net,
Method::POST,
&api_url("/mods"),
Some(serde_json::json!({ "modIds": mod_ids })),
)
.await?;
let indexes: HashMap<String, Vec<CfFileIndex>> = response
.data
.into_iter()
.map(|entry| (entry.id.to_string(), entry.latest_files_indexes))
.collect();
let mut picks: Vec<(String, u32)> = Vec::new();
for package in packages {
let Some(files) = indexes.get(&package.project_id) else {
continue;
};
if let Some(file_id) = pick_file_index(files, package.content_type, mc_version, loader)
{
picks.push((package.hash.clone(), file_id));
}
}
if picks.is_empty() {
return Ok(HashMap::new());
}
let file_ids: Vec<String> = picks
.iter()
.map(|(_, file_id)| file_id.to_string())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let versions: HashMap<String, VersionDetail> = self
.get_versions(&file_ids, ctx)
.await?
.into_iter()
.map(|version| (version.version_id.clone(), version))
.collect();
Ok(picks
.into_iter()
.filter_map(|(hash, file_id)| {
versions
.get(&file_id.to_string())
.map(|version| (hash, version.clone()))
})
.collect())
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CfModFiles {
id: u32,
#[serde(default)]
latest_files_indexes: Vec<CfFileIndex>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CfFileIndex {
game_version: String,
file_id: u32,
release_type: u8,
#[serde(default)]
mod_loader: Option<u8>,
}
fn pick_file_index(
files: &[CfFileIndex],
content_type: ContentType,
mc_version: &str,
loader: GameLoader,
) -> Option<u32> {
let wanted = base_game_version(mc_version);
let loader_fits = |index: &CfFileIndex| {
if content_type != ContentType::Mod || !loader.is_modded() {
return true;
}
index
.mod_loader
.and_then(|raw| cf_loader_to_game(CfLoader::from(raw)))
.is_some_and(|other| loader.compatible_with(other))
};
let fitting: Vec<&CfFileIndex> = files
.iter()
.filter(|index| base_game_version(&index.game_version) == wanted)
.filter(|index| loader_fits(index))
.collect();
fitting
.iter()
.filter(|index| index.release_type == 1)
.map(|index| index.file_id)
.max()
.or_else(|| fitting.iter().map(|index| index.file_id).max())
}
#[derive(Deserialize)]

View file

@ -8,8 +8,8 @@ pub use modrinth::ModrinthProvider;
pub use registry::PackageProviderRegistry;
use super::types::{
Page, ProjectDetail, ProjectSummary, SearchFilters, VersionDetail, VersionLookup,
VersionSummary,
InstalledPackage, Page, ProjectDetail, ProjectSummary, SearchFilters, VersionDetail,
VersionLookup, VersionSummary,
};
use crate::ctx::ContentCtx;
use crate::error::ContentResult;
@ -75,6 +75,14 @@ pub trait PackageProvider: Send + Sync {
ctx: &ContentCtx,
) -> ContentResult<VersionLookup>;
async fn latest_for_game_version(
&self,
packages: &[InstalledPackage],
mc_version: &str,
loader: oneclient_common::domain::GameLoader,
ctx: &ContentCtx,
) -> ContentResult<std::collections::HashMap<String, VersionDetail>>;
async fn list_categories(
&self,
_content_type: oneclient_common::domain::ContentType,

View file

@ -14,9 +14,9 @@ use crate::ctx::ContentCtx;
use crate::error::ContentResult;
use crate::packages::file_identity::FileIdentity;
use crate::packages::types::{
DependencyKind, GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary,
ReleaseType, SearchFilters, VersionDependency, VersionDetail, VersionFile, VersionLookup,
VersionSummary,
DependencyKind, GalleryImage, InstalledPackage, PackageBody, Page, ProjectDetail,
ProjectMember, ProjectSummary, ReleaseType, SearchFilters, VersionDependency, VersionDetail,
VersionFile, VersionLookup, VersionSummary,
};
use oneclient_common::constants::MODRINTH_API_URL;
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
@ -370,6 +370,102 @@ impl PackageProvider for ModrinthProvider {
Ok(out)
}
#[tracing::instrument(level = "debug", skip(self, packages, ctx))]
async fn latest_for_game_version(
&self,
packages: &[InstalledPackage],
mc_version: &str,
loader: GameLoader,
ctx: &ContentCtx,
) -> ContentResult<HashMap<String, VersionDetail>> {
let mut groups: HashMap<ContentType, Vec<String>> = HashMap::new();
for package in packages {
groups
.entry(package.content_type)
.or_default()
.push(package.hash.clone());
}
let mut out = HashMap::new();
for (content_type, hashes) in groups {
let loaders = modrinth_loader_filter(content_type, loader);
let releases =
latest_version_files(ctx, &hashes, mc_version, loaders.as_deref(), true).await?;
let missing: Vec<String> = hashes
.iter()
.filter(|hash| !releases.contains_key(*hash))
.cloned()
.collect();
out.extend(releases);
if !missing.is_empty() {
out.extend(
latest_version_files(ctx, &missing, mc_version, loaders.as_deref(), false)
.await?,
);
}
}
Ok(out)
}
}
fn modrinth_loader_filter(content_type: ContentType, loader: GameLoader) -> Option<Vec<String>> {
if content_type != ContentType::Mod || !loader.is_modded() {
return None;
}
Some(
[
GameLoader::Forge,
GameLoader::NeoForge,
GameLoader::Quilt,
GameLoader::Fabric,
GameLoader::Ornithe,
]
.into_iter()
.filter(|other| loader.compatible_with(*other))
.map(|other| other.modrinth_name().to_string())
.collect(),
)
}
async fn latest_version_files(
ctx: &ContentCtx,
hashes: &[String],
mc_version: &str,
loaders: Option<&[String]>,
releases_only: bool,
) -> ContentResult<HashMap<String, VersionDetail>> {
if hashes.is_empty() {
return Ok(HashMap::new());
}
let mut body = serde_json::json!({
"hashes": hashes,
"algorithm": "sha1",
"game_versions": [mc_version],
});
if let Some(loaders) = loaders {
body["loaders"] = serde_json::json!(loaders);
}
if releases_only {
body["version_types"] = serde_json::json!(["release"]);
}
let fetched: HashMap<String, ModrinthVersion> = fetch_json(
&ctx.net,
Method::POST,
&v2("/version_files/update"),
Some(body),
)
.await?;
Ok(fetched
.into_iter()
.map(|(hash, version)| (polyio::normalize_hash(&hash), version.into()))
.collect())
}
#[tracing::instrument(level = "debug", skip(client, team_ids))]

View file

@ -0,0 +1,990 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::{Arc, Mutex};
use futures_util::StreamExt;
use tokio::sync::OnceCell;
use chrono::Utc;
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
use oneclient_db::dao::artifact as artifact_dao;
use oneclient_db::dao::cluster_bundle as bundle_dao;
use oneclient_db::dao::release_migration_waitlist as waitlist_dao;
use oneclient_db::models::{ClusterRow, ReleaseMigrationWaitlistRow, SeenStatus};
use oneclient_events::GroupedProgressChild;
use crate::bundles::{BundlesManager, enabled_bundle_projects};
use crate::ctx::ContentCtx;
use crate::error::ContentResult;
use crate::packages::dependencies::{
ResolvedDependency, resolve_one, resolves_dependencies, supports_game_version,
};
use crate::packages::store::PackageStore;
use crate::packages::types::{
DependencyKind, InstalledPackage, ProjectDetail, VersionDependency, VersionDetail,
};
use crate::packages::updates::{Candidate, browser_installed_any};
pub const MIGRATED_CONTENT_TYPES: [ContentType; 3] = [
ContentType::Mod,
ContentType::ResourcePack,
ContentType::Shader,
];
const DEPENDENCY_DEPTH: usize = 4;
const SOURCE_CONCURRENCY: usize = 3;
const DEPENDENCY_CONCURRENCY: usize = 6;
#[derive(Debug, Clone)]
pub struct ReleaseMigrationPackage {
pub source_hash: String,
pub enabled: bool,
pub provider: ProviderId,
pub project_id: String,
pub content_type: ContentType,
pub display_name: String,
pub version: VersionDetail,
pub version_name: String,
pub size: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SkipReason {
NoCompatibleVersion,
ProviderUnavailable,
MissingDependency,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReleaseMigrationSkip {
pub source_hash: String,
pub enabled: bool,
pub provider: ProviderId,
pub project_id: String,
pub content_type: ContentType,
pub display_name: String,
pub reason: SkipReason,
}
#[derive(Debug, Clone)]
pub struct ReleaseMigrationDependency {
pub provider: ProviderId,
pub project: ProjectDetail,
pub version: VersionDetail,
pub required_by: Vec<String>,
}
impl ReleaseMigrationDependency {
#[must_use]
pub fn version_name(&self) -> String {
version_label(&self.version)
}
#[must_use]
pub fn size(&self) -> u64 {
self.version.primary_file().map_or(0, |file| file.size)
}
#[must_use]
pub fn is_needed_by(&self, selected_hashes: &HashSet<String>) -> bool {
self.required_by
.iter()
.any(|hash| selected_hashes.contains(hash))
}
#[must_use]
pub fn enabled_for(&self, packages: &[ReleaseMigrationPackage]) -> bool {
packages
.iter()
.any(|package| package.enabled && self.required_by.contains(&package.source_hash))
}
}
#[derive(Debug, Clone, Default)]
pub struct ReleaseMigrationPlan {
pub source_cluster_id: i64,
pub target_cluster_id: i64,
pub packages: Vec<ReleaseMigrationPackage>,
pub dependencies: Vec<ReleaseMigrationDependency>,
pub unavailable: Vec<ReleaseMigrationSkip>,
pub unreachable: bool,
}
impl ReleaseMigrationPlan {
#[must_use]
pub fn offered(&self, content_type: ContentType) -> usize {
self.packages
.iter()
.filter(|package| package.content_type == content_type)
.count()
}
}
fn version_label(version: &VersionDetail) -> String {
if version.version_number.is_empty() {
version.name.clone()
} else {
version.version_number.clone()
}
}
async fn migratable_candidates(cluster_id: i64, ctx: &ContentCtx) -> ContentResult<Vec<Candidate>> {
let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?;
let tracked = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?;
let bundle_projects: HashSet<String> = tracked
.iter()
.filter_map(|row| row.package_id.clone())
.collect();
let bundle_hashes: HashSet<String> = tracked.into_iter().map(|row| row.hash).collect();
Ok(
browser_installed_any(&linked, &bundle_hashes, &bundle_projects)
.into_iter()
.filter(|candidate| MIGRATED_CONTENT_TYPES.contains(&candidate.content_type))
.collect(),
)
}
#[tracing::instrument(level = "debug", skip(ctx))]
pub async fn has_migratable_packages(cluster_id: i64, ctx: &ContentCtx) -> ContentResult<bool> {
Ok(!migratable_candidates(cluster_id, ctx).await?.is_empty())
}
async fn target_projects(
target_cluster_id: i64,
bundles: &BundlesManager,
ctx: &ContentCtx,
) -> ContentResult<HashSet<String>> {
let mut projects: HashSet<String> = PackageStore::list_linked_artifacts(target_cluster_id, ctx)
.await?
.into_iter()
.filter_map(|linked| linked.project_id)
.collect();
projects.extend(
bundle_dao::list_bundle_tracked(&ctx.db, target_cluster_id)
.await?
.into_iter()
.filter_map(|row| row.package_id),
);
match enabled_bundle_projects(target_cluster_id, bundles, ctx).await {
Ok(from_bundles) => projects.extend(from_bundles),
Err(err) => tracing::warn!(
target_cluster_id,
error = %err,
"could not read the target's bundles, treating only installed packages as present"
),
}
Ok(projects)
}
fn fits_target(
version: &VersionDetail,
content_type: ContentType,
mc_version: &str,
loader: GameLoader,
) -> bool {
if version.primary_file().is_none()
|| !supports_game_version(&version.game_versions, mc_version)
{
return false;
}
content_type != ContentType::Mod
|| !loader.is_modded()
|| version.loaders.is_empty()
|| version
.loaders
.iter()
.any(|other| loader.compatible_with(*other))
}
type Lookup = Option<ResolvedDependency>;
type LookupCells = HashMap<(ProviderId, String), Arc<OnceCell<Lookup>>>;
struct Unreachable;
#[derive(Default)]
pub struct DependencyCache {
entries: Mutex<LookupCells>,
}
fn dependency_key(dep: &VersionDependency) -> Option<String> {
dep.version_id
.as_ref()
.map(|version_id| format!("version:{version_id}"))
.or_else(|| {
dep.project_id
.as_ref()
.map(|project_id| format!("project:{project_id}"))
})
}
impl DependencyCache {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn cell(&self, provider_id: ProviderId, key: String) -> Arc<OnceCell<Lookup>> {
Arc::clone(
self.entries
.lock()
.unwrap()
.entry((provider_id, key))
.or_default(),
)
}
async fn lookup(
&self,
provider_id: ProviderId,
dep: &VersionDependency,
target: &ClusterRow,
ctx: &ContentCtx,
) -> Result<Lookup, Unreachable> {
let Some(key) = dependency_key(dep) else {
return Ok(None);
};
let cell = self.cell(provider_id, key);
cell.get_or_try_init(|| async {
let provider = match ctx.providers.get(provider_id) {
Ok(provider) => provider,
Err(_) => return Ok(None),
};
match resolve_one(provider, dep, target, ctx).await {
Ok(resolved) => Ok(resolved),
Err(err) if err.is_transient() => Err(Unreachable),
Err(err) => {
tracing::warn!(?dep, %err, "could not resolve a dependency for release migration");
Ok(None)
}
}
})
.await
.cloned()
}
async fn resolve(
&self,
provider_id: ProviderId,
root: &VersionDetail,
target: &ClusterRow,
present: &HashSet<String>,
ctx: &ContentCtx,
) -> Result<Option<Vec<ResolvedDependency>>, Unreachable> {
let mut seen: HashSet<String> = HashSet::new();
seen.insert(root.project_id.clone());
let mut queue: VecDeque<(VersionDependency, usize)> = root
.dependencies
.iter()
.filter(|dep| dep.kind == DependencyKind::Required)
.map(|dep| (dep.clone(), 1))
.collect();
let mut found = Vec::new();
while let Some((dep, depth)) = queue.pop_front() {
if let Some(project_id) = &dep.project_id
&& (seen.contains(project_id) || present.contains(project_id))
{
continue;
}
let Some(resolved) = self.lookup(provider_id, &dep, target, ctx).await? else {
return Ok(None);
};
let project_id = resolved.version.project_id.clone();
if present.contains(&project_id) || !seen.insert(project_id) {
continue;
}
if depth < DEPENDENCY_DEPTH {
for next in resolved
.version
.dependencies
.iter()
.filter(|next| next.kind == DependencyKind::Required)
{
queue.push_back((next.clone(), depth + 1));
}
}
found.push(resolved);
}
Ok(Some(found))
}
}
#[tracing::instrument(level = "debug", skip(source_cluster_ids, bundles, ctx))]
pub async fn plan_release_migrations(
source_cluster_ids: &[i64],
target_cluster_id: i64,
bundles: &BundlesManager,
ctx: &ContentCtx,
) -> Vec<(i64, ContentResult<ReleaseMigrationPlan>)> {
let cache = DependencyCache::new();
futures_util::stream::iter(source_cluster_ids.iter().copied().map(|source_cluster_id| {
let cache = &cache;
async move {
let plan = plan_release_migration_cached(
source_cluster_id,
target_cluster_id,
cache,
bundles,
ctx,
)
.await;
(source_cluster_id, plan)
}
}))
.buffer_unordered(SOURCE_CONCURRENCY)
.collect::<Vec<_>>()
.await
}
#[tracing::instrument(level = "debug", skip(cache, bundles, ctx))]
async fn plan_release_migration_cached(
source_cluster_id: i64,
target_cluster_id: i64,
cache: &DependencyCache,
bundles: &BundlesManager,
ctx: &ContentCtx,
) -> ContentResult<ReleaseMigrationPlan> {
let target = PackageStore::get_cluster(target_cluster_id, ctx).await?;
let present = target_projects(target_cluster_id, bundles, ctx).await?;
let candidates: Vec<Candidate> = migratable_candidates(source_cluster_id, ctx)
.await?
.into_iter()
.filter(|candidate| !present.contains(&candidate.project_id))
.collect();
let evaluation = evaluate_candidates(candidates, &target, &present, cache, ctx).await;
let mut plan = ReleaseMigrationPlan {
source_cluster_id,
target_cluster_id,
packages: evaluation.packages,
dependencies: evaluation.dependencies,
unavailable: evaluation.unavailable,
unreachable: evaluation.unreachable,
};
plan.packages
.sort_by_key(|package| package.display_name.to_lowercase());
plan.dependencies
.sort_by_key(|dependency| dependency.project.name.to_lowercase());
plan.unavailable
.sort_by_key(|skip| skip.display_name.to_lowercase());
Ok(plan)
}
#[derive(Default)]
struct Evaluation {
packages: Vec<ReleaseMigrationPackage>,
dependencies: Vec<ReleaseMigrationDependency>,
unavailable: Vec<ReleaseMigrationSkip>,
unreachable: bool,
}
async fn evaluate_candidates(
candidates: Vec<Candidate>,
target: &ClusterRow,
present: &HashSet<String>,
cache: &DependencyCache,
ctx: &ContentCtx,
) -> Evaluation {
let loader = GameLoader::from_repr(target.mc_loader as u8).unwrap_or(GameLoader::Vanilla);
let mut evaluation = Evaluation::default();
let mut by_provider: HashMap<ProviderId, Vec<Candidate>> = HashMap::new();
for candidate in candidates {
by_provider
.entry(candidate.provider)
.or_default()
.push(candidate);
}
let mut offered: Vec<ReleaseMigrationPackage> = Vec::new();
for (provider_id, candidates) in by_provider {
let installed: Vec<InstalledPackage> = candidates
.iter()
.map(|candidate| InstalledPackage {
hash: candidate.hash.clone(),
project_id: candidate.project_id.clone(),
content_type: candidate.content_type,
})
.collect();
let found = match ctx.providers.get(provider_id) {
Ok(provider) => {
provider
.latest_for_game_version(&installed, &target.mc_version, loader, ctx)
.await
}
Err(err) => Err(err),
};
let found = match found {
Ok(found) => found,
Err(err) if err.is_transient() => {
tracing::warn!(provider = ?provider_id, error = %err, "release migration check could not reach the provider");
evaluation.unreachable = true;
continue;
}
Err(err) => {
tracing::warn!(provider = ?provider_id, error = %err, "release migration check failed for the provider");
evaluation
.unavailable
.extend(
candidates
.into_iter()
.map(|candidate| ReleaseMigrationSkip {
source_hash: candidate.hash,
enabled: candidate.enabled,
provider: provider_id,
project_id: candidate.project_id,
content_type: candidate.content_type,
display_name: candidate.display_name,
reason: SkipReason::ProviderUnavailable,
}),
);
continue;
}
};
for candidate in candidates {
let version = found.get(&candidate.hash).filter(|version| {
fits_target(version, candidate.content_type, &target.mc_version, loader)
});
match version {
Some(version) => offered.push(ReleaseMigrationPackage {
source_hash: candidate.hash,
enabled: candidate.enabled,
provider: provider_id,
project_id: candidate.project_id,
content_type: candidate.content_type,
display_name: candidate.display_name,
version: version.clone(),
version_name: version_label(version),
size: version.primary_file().map_or(0, |file| file.size),
}),
None => evaluation.unavailable.push(ReleaseMigrationSkip {
source_hash: candidate.hash,
enabled: candidate.enabled,
provider: provider_id,
project_id: candidate.project_id,
content_type: candidate.content_type,
display_name: candidate.display_name,
reason: SkipReason::NoCompatibleVersion,
}),
}
}
}
if evaluation.unreachable {
return evaluation;
}
let resolved_packages =
futures_util::stream::iter(offered.into_iter().map(|package| async move {
let resolved = if resolves_dependencies(package.content_type) {
cache
.resolve(package.provider, &package.version, target, present, ctx)
.await
} else {
Ok(Some(Vec::new()))
};
(package, resolved)
}))
.buffer_unordered(DEPENDENCY_CONCURRENCY)
.collect::<Vec<_>>()
.await;
let mut dependencies: Vec<ReleaseMigrationDependency> = Vec::new();
for (package, resolved) in resolved_packages {
let resolved = match resolved {
Ok(resolved) => resolved,
Err(Unreachable) => {
evaluation.unreachable = true;
return evaluation;
}
};
let Some(resolved) = resolved else {
evaluation.unavailable.push(ReleaseMigrationSkip {
source_hash: package.source_hash,
enabled: package.enabled,
provider: package.provider,
project_id: package.project_id,
content_type: package.content_type,
display_name: package.display_name,
reason: SkipReason::MissingDependency,
});
continue;
};
for dependency in resolved {
match dependencies.iter_mut().find(|existing| {
existing.provider == package.provider
&& existing.project.id == dependency.project.id
}) {
Some(existing) => existing.required_by.push(package.source_hash.clone()),
None => dependencies.push(ReleaseMigrationDependency {
provider: package.provider,
project: dependency.project,
version: dependency.version,
required_by: vec![package.source_hash.clone()],
}),
}
}
evaluation.packages.push(package);
}
let libraries: HashSet<String> = evaluation
.packages
.iter()
.filter(|package| {
dependencies.iter().any(|dependency| {
dependency.provider == package.provider
&& dependency.project.id == package.project_id
})
})
.map(|package| package.source_hash.clone())
.collect();
evaluation
.packages
.retain(|package| !libraries.contains(&package.source_hash));
for dependency in &mut dependencies {
dependency
.required_by
.retain(|hash| !libraries.contains(hash));
}
dependencies.retain(|dependency| !dependency.required_by.is_empty());
evaluation.dependencies = dependencies;
evaluation
}
pub const WAITLIST_DAYS: i64 = 30;
#[tracing::instrument(level = "debug", skip(skips, ctx))]
pub async fn add_to_waitlist(
target_cluster_id: i64,
skips: &[ReleaseMigrationSkip],
ctx: &ContentCtx,
) -> ContentResult<()> {
let now = Utc::now();
let added_at = now.to_rfc3339();
let expires_at = (now + chrono::Duration::days(WAITLIST_DAYS)).to_rfc3339();
for skip in skips {
waitlist_dao::upsert(
&ctx.db,
target_cluster_id,
skip.provider as i64,
&skip.project_id,
skip.content_type as i64,
&skip.source_hash,
&skip.display_name,
&added_at,
&expires_at,
skip.enabled,
)
.await?;
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct WaitlistInstall {
pub cluster_id: i64,
pub cluster_name: String,
pub mc_version: String,
pub names: Vec<String>,
}
#[tracing::instrument(level = "debug", skip(bundles, is_running, ctx))]
pub async fn process_waitlist(
bundles: &BundlesManager,
is_running: impl Fn(i64) -> bool,
ctx: &ContentCtx,
) -> ContentResult<Vec<WaitlistInstall>> {
let expired = waitlist_dao::delete_expired(&ctx.db, &Utc::now().to_rfc3339()).await?;
if expired > 0 {
tracing::info!(
expired,
"dropped expired release migration waitlist entries"
);
}
let mut by_cluster: std::collections::BTreeMap<i64, Vec<ReleaseMigrationWaitlistRow>> =
std::collections::BTreeMap::new();
let rows = waitlist_dao::list_all(&ctx.db).await?;
tracing::info!(
entries = rows.len(),
expired,
"checking the release migration waitlist for new builds"
);
for row in rows {
by_cluster
.entry(row.target_cluster_id)
.or_default()
.push(row);
}
let cache = DependencyCache::new();
let mut installs = Vec::new();
for (cluster_id, rows) in by_cluster {
if is_running(cluster_id) {
tracing::info!(
cluster_id,
waiting = rows.len(),
"skipping the waitlist check for a running cluster"
);
continue;
}
let target = match PackageStore::get_cluster(cluster_id, ctx).await {
Ok(target) => target,
Err(err) => {
tracing::warn!(cluster_id, %err, "could not read a waitlisted cluster, skipping it");
continue;
}
};
let present = match target_projects(cluster_id, bundles, ctx).await {
Ok(present) => present,
Err(err) => {
tracing::warn!(cluster_id, %err, "could not read a waitlisted cluster's packages, skipping it");
continue;
}
};
let mut candidates = Vec::new();
for row in rows {
if present.contains(&row.project_id) {
tracing::info!(
cluster_id,
package = %row.display_name,
"waitlisted package is already in the cluster, dropping it"
);
waitlist_dao::delete(&ctx.db, cluster_id, row.provider, &row.project_id).await?;
continue;
}
let (Some(provider), Some(content_type)) = (
ProviderId::from_repr(row.provider as u8),
ContentType::from_repr(row.content_type as u8),
) else {
waitlist_dao::delete(&ctx.db, cluster_id, row.provider, &row.project_id).await?;
continue;
};
candidates.push(Candidate {
hash: row.source_hash,
provider,
content_type,
project_id: row.project_id,
version_id: String::new(),
display_name: row.display_name,
display_version: String::new(),
published_at: None,
enabled: row.enabled != 0,
});
}
if candidates.is_empty() {
continue;
}
let waitlisted: HashSet<(ProviderId, String)> = candidates
.iter()
.map(|candidate| (candidate.provider, candidate.project_id.clone()))
.collect();
let checking: Vec<String> = candidates
.iter()
.map(|candidate| candidate.display_name.clone())
.collect();
tracing::info!(
cluster_id,
cluster = %target.name,
mc_version = %target.mc_version,
packages = ?checking,
"checking waitlisted packages for {} builds",
target.mc_version
);
let evaluation = evaluate_candidates(candidates, &target, &present, &cache, ctx).await;
if evaluation.unreachable {
tracing::warn!(
cluster_id,
"a provider could not be reached, keeping the waitlist for the next launch"
);
break;
}
tracing::info!(
cluster_id,
mc_version = %target.mc_version,
available = ?evaluation
.packages
.iter()
.map(|package| format!("{} {}", package.display_name, package.version_name))
.collect::<Vec<_>>(),
dependencies = ?evaluation
.dependencies
.iter()
.map(|dependency| dependency.project.name.clone())
.collect::<Vec<_>>(),
still_waiting = ?evaluation
.unavailable
.iter()
.map(|skip| format!("{} ({:?})", skip.display_name, skip.reason))
.collect::<Vec<_>>(),
"waitlist check finished for {} builds",
target.mc_version
);
let mut names = Vec::new();
let mut blocked: HashSet<String> = HashSet::new();
for dependency in &evaluation.dependencies {
let enabled = dependency.enabled_for(&evaluation.packages);
match apply_release_migration_dependency(cluster_id, dependency, enabled, None, ctx)
.await
{
Ok(_) => {
if waitlisted.contains(&(dependency.provider, dependency.project.id.clone())) {
waitlist_dao::delete(
&ctx.db,
cluster_id,
dependency.provider as i64,
&dependency.project.id,
)
.await?;
names.push(dependency.project.name.clone());
}
}
Err(err) => {
tracing::warn!(dependency = %dependency.project.name, %err, "waitlisted package dependency failed to install");
blocked.extend(dependency.required_by.iter().cloned());
}
}
}
for package in &evaluation.packages {
if blocked.contains(&package.source_hash) {
continue;
}
match apply_release_migration_package(cluster_id, package, None, ctx).await {
Ok(_) => {
waitlist_dao::delete(
&ctx.db,
cluster_id,
package.provider as i64,
&package.project_id,
)
.await?;
names.push(package.display_name.clone());
}
Err(err) => {
tracing::warn!(package = %package.display_name, %err, "waitlisted package failed to install")
}
}
}
if !names.is_empty() {
tracing::info!(
cluster_id,
cluster = %target.name,
installed = ?names,
"installed waitlisted packages that gained a {} build",
target.mc_version
);
installs.push(WaitlistInstall {
cluster_id,
cluster_name: target.name.clone(),
mc_version: target.mc_version.clone(),
names,
});
}
}
Ok(installs)
}
#[tracing::instrument(level = "debug", skip(dependency, child, ctx), fields(project_id = %dependency.project.id))]
pub async fn apply_release_migration_dependency(
target_cluster_id: i64,
dependency: &ReleaseMigrationDependency,
enabled: bool,
child: Option<&GroupedProgressChild>,
ctx: &ContentCtx,
) -> ContentResult<String> {
let (installed, _) = PackageStore::install_to_cluster(
dependency.provider,
&dependency.project,
&dependency.version,
target_cluster_id,
true,
false,
child,
ctx,
)
.await?;
artifact_dao::set_seen_status(&ctx.db, target_cluster_id, &installed.hash, SeenStatus::New)
.await?;
if !enabled {
PackageStore::set_artifact_enabled_to(target_cluster_id, &installed.hash, false, ctx)
.await?;
}
Ok(installed.hash)
}
#[tracing::instrument(level = "debug", skip(package, child, ctx), fields(project_id = %package.project_id))]
pub async fn apply_release_migration_package(
target_cluster_id: i64,
package: &ReleaseMigrationPackage,
child: Option<&GroupedProgressChild>,
ctx: &ContentCtx,
) -> ContentResult<String> {
let project = ctx
.providers
.get(package.provider)?
.get_project(&package.project_id, ctx)
.await?;
let (installed, _) = PackageStore::install_to_cluster(
package.provider,
&project,
&package.version,
target_cluster_id,
true,
false,
child,
ctx,
)
.await?;
artifact_dao::set_seen_status(&ctx.db, target_cluster_id, &installed.hash, SeenStatus::New)
.await?;
if !package.enabled {
PackageStore::set_artifact_enabled_to(target_cluster_id, &installed.hash, false, ctx)
.await?;
}
Ok(installed.hash)
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use super::*;
use crate::packages::types::VersionFile;
fn version(game_versions: &[&str], loaders: Vec<GameLoader>) -> VersionDetail {
VersionDetail {
version_id: "v".into(),
project_id: "p".into(),
name: "name".into(),
version_number: "1.0".into(),
changelog: None,
game_versions: game_versions.iter().map(|v| (*v).to_string()).collect(),
loaders,
published: Utc::now(),
downloads: 0,
files: vec![VersionFile {
sha1: "a".into(),
url: "u".into(),
file_name: "f.jar".into(),
primary: true,
size: 1,
fingerprint: None,
}],
dependencies: Vec::new(),
}
}
#[test]
fn a_mod_for_another_loader_is_not_offered() {
let forge = version(&["26.3"], vec![GameLoader::Forge]);
assert!(!fits_target(
&forge,
ContentType::Mod,
"26.3",
GameLoader::Fabric
));
}
#[test]
fn a_mod_for_an_older_game_version_is_not_offered() {
let old = version(&["26.2"], vec![GameLoader::Fabric]);
assert!(!fits_target(
&old,
ContentType::Mod,
"26.3",
GameLoader::Fabric
));
}
#[test]
fn resource_packs_ignore_the_cluster_loader() {
let pack = version(&["26.3"], vec![GameLoader::Vanilla]);
assert!(fits_target(
&pack,
ContentType::ResourcePack,
"26.3",
GameLoader::Fabric
));
}
#[test]
fn a_version_without_files_is_not_offered() {
let mut empty = version(&["26.3"], vec![GameLoader::Fabric]);
empty.files.clear();
assert!(!fits_target(
&empty,
ContentType::Mod,
"26.3",
GameLoader::Fabric
));
}
#[test]
fn packages_keep_a_stable_order_however_the_checks_finish() {
let mut packages = vec!["Zoomify", "blur", "Sodium"];
packages.sort_by_key(|name| name.to_lowercase());
assert_eq!(packages, vec!["blur", "Sodium", "Zoomify"]);
}
#[test]
fn pinned_versions_and_projects_cache_separately() {
let pinned = VersionDependency {
project_id: Some("fabric-api".into()),
version_id: Some("v1".into()),
kind: DependencyKind::Required,
};
let loose = VersionDependency {
project_id: Some("fabric-api".into()),
version_id: None,
kind: DependencyKind::Required,
};
assert_eq!(dependency_key(&pinned).as_deref(), Some("version:v1"));
assert_eq!(
dependency_key(&loose).as_deref(),
Some("project:fabric-api")
);
}
}

View file

@ -232,6 +232,13 @@ pub struct ProviderReleaseInfo {
}
pub type VersionLookup = HashMap<String, VersionDetail>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstalledPackage {
pub hash: String,
pub project_id: String,
pub content_type: ContentType,
}
pub type ProviderVersionLookup = HashMap<String, (ProviderId, VersionDetail)>;
#[derive(Debug, Clone, Serialize, Deserialize)]

View file

@ -10,7 +10,7 @@ use std::collections::HashSet;
use chrono::{DateTime, Utc};
use futures_util::StreamExt;
use oneclient_common::domain::ProviderId;
use oneclient_common::domain::{ContentType, ProviderId};
use oneclient_db::dao::artifact as artifact_dao;
use oneclient_db::dao::browser_package_update as update_dao;
use oneclient_db::dao::cluster_bundle as bundle_dao;
@ -81,25 +81,37 @@ impl BrowserUpdateCheck {
}
}
struct Candidate {
hash: String,
provider: ProviderId,
project_id: String,
version_id: String,
display_name: String,
display_version: String,
published_at: Option<DateTime<Utc>>,
pub(crate) struct Candidate {
pub(crate) hash: String,
pub(crate) provider: ProviderId,
pub(crate) content_type: ContentType,
pub(crate) project_id: String,
pub(crate) version_id: String,
pub(crate) display_name: String,
pub(crate) display_version: String,
pub(crate) published_at: Option<DateTime<Utc>>,
pub(crate) enabled: bool,
}
/// A missing bundle tracking row is the marker for "the user added this themselves"
fn browser_installed(
pub(crate) fn browser_installed(
linked: &[LinkedArtifactInfo],
bundle_hashes: &HashSet<String>,
bundle_projects: &HashSet<String>,
) -> Vec<Candidate> {
browser_installed_any(linked, bundle_hashes, bundle_projects)
.into_iter()
.filter(|candidate| candidate.enabled)
.collect()
}
pub(crate) fn browser_installed_any(
linked: &[LinkedArtifactInfo],
bundle_hashes: &HashSet<String>,
bundle_projects: &HashSet<String>,
) -> Vec<Candidate> {
linked
.iter()
.filter(|info| info.enabled)
.filter(|info| !bundle_hashes.contains(&info.hash))
.filter(|info| {
info.project_id
@ -114,6 +126,7 @@ fn browser_installed(
Some(Candidate {
hash: info.hash.clone(),
provider,
content_type: info.content_type,
project_id: info.project_id.clone()?,
version_id: info.version_id.clone()?,
display_name: info
@ -122,6 +135,7 @@ fn browser_installed(
.unwrap_or_else(|| info.file_name.clone()),
display_version: info.display_version.clone().unwrap_or_default(),
published_at: info.published_at.as_deref().and_then(parse_published),
enabled: info.enabled,
})
})
.collect()
@ -356,7 +370,7 @@ pub async fn apply_browser_package_update(
.await?
.map(|link| link.enabled != 0);
install_new_dependencies(update, &project, &version, ctx).await;
install_new_dependencies(update.provider, update.cluster_id, &project, &version, ctx).await;
// Compatibility is not re-checked
// a provider disagreeing at install time would strand the user on a build
@ -399,9 +413,10 @@ pub async fn apply_browser_package_update(
Ok(installed.hash)
}
#[tracing::instrument(level = "debug", skip(update, project, version, ctx), fields(cluster_id = update.cluster_id))]
async fn install_new_dependencies(
update: &BrowserPackageUpdate,
#[tracing::instrument(level = "debug", skip(project, version, ctx))]
pub(crate) async fn install_new_dependencies(
provider: ProviderId,
cluster_id: i64,
project: &crate::packages::types::ProjectDetail,
version: &crate::packages::types::VersionDetail,
ctx: &ContentCtx,
@ -410,8 +425,7 @@ async fn install_new_dependencies(
return;
}
let resolution = match resolve_required(update.provider, version, update.cluster_id, ctx).await
{
let resolution = match resolve_required(provider, version, cluster_id, ctx).await {
Ok(resolution) => resolution,
Err(err) => {
tracing::warn!(%err, "dependency resolution failed, updating the package alone");
@ -425,10 +439,10 @@ async fn install_new_dependencies(
for dependency in &resolution.install {
match PackageStore::install_to_cluster(
update.provider,
provider,
&dependency.project,
&dependency.version,
update.cluster_id,
cluster_id,
true,
false,
None,
@ -443,7 +457,7 @@ async fn install_new_dependencies(
);
if let Err(err) = artifact_dao::set_seen_status(
&ctx.db,
update.cluster_id,
cluster_id,
&artifact.hash,
SeenStatus::New,
)
@ -464,7 +478,6 @@ async fn install_new_dependencies(
#[cfg(test)]
mod tests {
use super::*;
use oneclient_common::domain::ContentType;
fn at(raw: &str) -> DateTime<Utc> {
parse_published(raw).expect("test dates are rfc 3339")

View file

@ -1,6 +1,7 @@
mod migrate;
pub(crate) mod prepare;
mod provision;
mod release_migration;
mod unlink_legacy;
pub use migrate::apply_remote_migrations;
@ -8,6 +9,10 @@ pub use prepare::{
estimate_cluster_download, prepare_cluster, prepare_cluster_locked, required_java_major,
};
pub use provision::{ensure_from_bundles, ensure_from_versions};
pub use release_migration::{
OfferLookup, ReleaseMigrationOffer, manual_migration_offer, rank_migration_sources,
record_new_versions, release_migration_offer,
};
pub use unlink_legacy::{SweepReport, unlink_legacy_cluster_content};
pub use oneclient_cluster::{

View file

@ -0,0 +1,301 @@
use std::cmp::Reverse;
use std::str::FromStr;
use oneclient_common::domain::GameLoader;
use oneclient_cluster::Cluster;
use oneclient_common::version::parse_mc_version;
use oneclient_content::packages::release_migration::has_migratable_packages;
use crate::LauncherResult;
use crate::state::LauncherState;
use crate::versions::{ReleaseTarget, RemoteMigration};
#[derive(Debug, Clone)]
pub struct ReleaseMigrationOffer {
pub release: ReleaseTarget,
pub target: Cluster,
pub sources: Vec<Cluster>,
}
#[derive(Debug, Clone)]
pub enum OfferLookup {
MissingCluster,
NoSources,
Offer(Box<ReleaseMigrationOffer>),
}
fn version_order(mc_version: &str) -> Option<(u32, u32, u32)> {
let parsed = parse_mc_version(mc_version)?;
Some((
parsed.major,
parsed.minor.unwrap_or(0),
parsed.patch.unwrap_or(0),
))
}
fn source_rank(
target: (u32, u32, u32),
source: (u32, u32, u32),
) -> (bool, Reverse<(u32, u32, u32)>) {
(source > target, Reverse(source))
}
#[must_use]
pub fn can_migrate_manually(source: GameLoader, target: GameLoader) -> bool {
source == target || (source == GameLoader::Fabric && target == GameLoader::Ornithe)
}
fn is_migration_destination(target: &ReleaseTarget, rules: &[RemoteMigration]) -> bool {
rules.iter().any(|rule| {
rule.to.mc_version == target.mc_version
&& GameLoader::from_str(&rule.from.loader).is_ok_and(|loader| loader == target.loader)
})
}
#[tracing::instrument(skip(state))]
pub async fn record_new_versions(state: &LauncherState) -> LauncherResult<()> {
let rules = state.versions.migrations().await;
let mut added: Vec<ReleaseTarget> = Vec::new();
for target in state.versions.take_added_versions() {
if is_migration_destination(&target, &rules) {
continue;
}
if !state.versions.shows_initial_migration(&target).await {
tracing::info!(
mc_version = %target.mc_version,
loader = %target.loader,
"new version opts out of the initial migration offer"
);
continue;
}
added.push(target);
}
if added.is_empty() {
return Ok(());
}
let updated = {
let mut settings = state.settings.write();
for target in &added {
let key = target.key();
if !settings.pending_release_migrations.contains(&key) {
settings.pending_release_migrations.push(key);
}
}
settings.clone()
};
crate::settings::store::save_settings(&updated).await
}
#[tracing::instrument(skip(state))]
pub async fn release_migration_offer(
state: &LauncherState,
release: &ReleaseTarget,
) -> LauncherResult<OfferLookup> {
let clusters = state.clusters.list().await?;
let content = state.services.content();
let Some(target) = clusters
.iter()
.filter(|cluster| {
cluster.mc_loader == release.loader && cluster.mc_version == release.mc_version
})
.min_by_key(|cluster| cluster.created_at)
else {
return Ok(OfferLookup::MissingCluster);
};
offer_for(target, &clusters, &content).await
}
#[tracing::instrument(skip(state))]
pub async fn manual_migration_offer(
state: &LauncherState,
target_cluster_id: i64,
source_cluster_id: i64,
) -> LauncherResult<OfferLookup> {
let clusters = state.clusters.list().await?;
let content = state.services.content();
let (Some(target), Some(source)) = (
clusters
.iter()
.find(|cluster| cluster.id == target_cluster_id),
clusters
.iter()
.find(|cluster| cluster.id == source_cluster_id),
) else {
return Ok(OfferLookup::MissingCluster);
};
if source.id == target.id
|| !can_migrate_manually(source.mc_loader, target.mc_loader)
|| !has_migratable_packages(source.id, &content).await?
{
return Ok(OfferLookup::NoSources);
}
Ok(OfferLookup::Offer(Box::new(ReleaseMigrationOffer {
release: ReleaseTarget {
mc_version: target.mc_version.clone(),
loader: target.mc_loader,
},
target: target.clone(),
sources: vec![source.clone()],
})))
}
#[must_use]
pub fn rank_migration_sources(target: &Cluster, clusters: &[Cluster]) -> Vec<Cluster> {
let target_order = version_order(&target.mc_version);
let mut sources: Vec<Cluster> = clusters
.iter()
.filter(|cluster| {
cluster.id != target.id && can_migrate_manually(cluster.mc_loader, target.mc_loader)
})
.cloned()
.collect();
sources.sort_by_key(|cluster| {
(
cluster.mc_loader != target.mc_loader,
target_order
.zip(version_order(&cluster.mc_version))
.map(|(target, source)| source_rank(target, source)),
Reverse(cluster.last_played),
)
});
sources
}
async fn offer_for(
target: &Cluster,
clusters: &[Cluster],
content: &oneclient_content::ContentCtx,
) -> LauncherResult<OfferLookup> {
let Some(target_order) = version_order(&target.mc_version) else {
return Ok(OfferLookup::NoSources);
};
let mut sources = Vec::new();
for cluster in clusters {
if cluster.id == target.id || cluster.mc_loader != target.mc_loader {
continue;
}
if !version_order(&cluster.mc_version).is_some_and(|order| order != target_order) {
continue;
}
if has_migratable_packages(cluster.id, content).await? {
sources.push(cluster.clone());
}
}
if sources.is_empty() {
return Ok(OfferLookup::NoSources);
}
sources.sort_by_key(|cluster| {
(
version_order(&cluster.mc_version).map(|order| source_rank(target_order, order)),
Reverse(cluster.last_played),
)
});
Ok(OfferLookup::Offer(Box::new(ReleaseMigrationOffer {
release: ReleaseTarget {
mc_version: target.mc_version.clone(),
loader: target.mc_loader,
},
target: target.clone(),
sources,
})))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn newer_minor_orders_above_older_minor() {
assert!(version_order("26.2") < version_order("26.3"));
assert!(version_order("26.1.2") < version_order("26.2"));
assert!(version_order("1.21.4") < version_order("26.1"));
}
#[test]
fn upgrades_prefer_the_newest_older_cluster() {
let target = version_order("26.3").unwrap();
let mut sources = vec!["26.1", "26.4", "26.2", "1.21.4"];
sources.sort_by_key(|v| source_rank(target, version_order(v).unwrap()));
assert_eq!(sources, vec!["26.2", "26.1", "1.21.4", "26.4"]);
}
#[test]
fn downgrades_prefer_the_newest_cluster() {
let target = version_order("1.8.9").unwrap();
let mut sources = vec!["1.21.4", "26.2", "26.1"];
sources.sort_by_key(|v| source_rank(target, version_order(v).unwrap()));
assert_eq!(sources, vec!["26.2", "26.1", "1.21.4"]);
}
#[test]
fn a_version_that_existing_clusters_move_to_is_not_a_new_release() {
let rules = vec![RemoteMigration {
id: "x".into(),
from: crate::versions::MigrationSource {
mc_version: "26.1".into(),
loader: "fabric".into(),
},
to: crate::versions::MigrationTarget {
mc_version: "26.1.2".into(),
},
}];
let moved = ReleaseTarget {
mc_version: "26.1.2".into(),
loader: GameLoader::Fabric,
};
let released = ReleaseTarget {
mc_version: "26.3".into(),
loader: GameLoader::Fabric,
};
let other_loader = ReleaseTarget {
mc_version: "26.1.2".into(),
loader: GameLoader::NeoForge,
};
assert!(is_migration_destination(&moved, &rules));
assert!(!is_migration_destination(&released, &rules));
assert!(!is_migration_destination(&other_loader, &rules));
}
#[test]
fn a_same_version_source_comes_before_older_ones() {
let target = version_order("26.2").unwrap();
let mut sources = vec!["26.1.2", "26.2", "1.21.11"];
sources.sort_by_key(|v| source_rank(target, version_order(v).unwrap()));
assert_eq!(sources, vec!["26.2", "26.1.2", "1.21.11"]);
}
#[test]
fn fabric_sources_can_fill_an_ornithe_cluster_manually() {
assert!(can_migrate_manually(
GameLoader::Fabric,
GameLoader::Ornithe
));
assert!(can_migrate_manually(GameLoader::Fabric, GameLoader::Fabric));
assert!(!can_migrate_manually(
GameLoader::Ornithe,
GameLoader::Fabric
));
assert!(!can_migrate_manually(
GameLoader::Forge,
GameLoader::Ornithe
));
}
#[test]
fn a_bare_minor_orders_below_its_patches() {
assert!(version_order("26.3") < version_order("26.3.1"));
}
}

View file

@ -74,5 +74,6 @@ pub use state::shutdown;
pub use tos::{TermsDocument, fetch_terms};
pub use verify::{ClusterVerifyReport, verify_cluster_files};
pub use versions::{
RemoteMigration, VersionMetadata, VersionsManager, VersionsManifest, resolve_migration_chain,
ReleaseTarget, RemoteMigration, VersionMetadata, VersionsManager, VersionsManifest,
resolve_migration_chain,
};

View file

@ -74,6 +74,7 @@ pub struct LauncherSettings {
pub declined_tos: bool,
pub seen_versions: Vec<String>,
pub seen_changelog_version: Option<String>,
pub pending_release_migrations: Vec<String>,
pub modrinth_api_key: Option<String>,
pub curseforge_api_key: Option<String>,
pub custom_api_endpoint: Option<String>,
@ -119,6 +120,7 @@ impl Default for LauncherSettings {
declined_tos: false,
seen_versions: Vec::new(),
seen_changelog_version: None,
pending_release_migrations: Vec::new(),
modrinth_api_key: None,
curseforge_api_key: None,
custom_api_endpoint: None,

View file

@ -147,6 +147,9 @@ pub fn run_startup_tasks(state: &Arc<LauncherState>) {
if let Err(err) = versions_res {
tracing::error!("versions manifest sync failed: {err:#}");
}
if let Err(err) = crate::clusters::record_new_versions(&background).await {
tracing::warn!("could not record new versions for migration: {err:#}");
}
if let Err(err) = bundles_res {
tracing::error!("bundle catalog sync failed: {err:#}");
}

View file

@ -2,12 +2,15 @@ use tokio::sync::RwLock;
use crate::LauncherResult;
use crate::state::LauncherServices;
use crate::versions::manifest::{RemoteMigration, VersionMetadata, VersionsManifest};
use crate::versions::manifest::{
ReleaseTarget, RemoteMigration, VersionMetadata, VersionsManifest, added_release_targets,
};
use oneclient_common::paths;
use oneclient_net::{EtagPolicy, fetch_cached};
pub struct VersionsManager {
manifest: RwLock<VersionsManifest>,
added: std::sync::Mutex<Vec<ReleaseTarget>>,
}
impl VersionsManager {
@ -15,6 +18,7 @@ impl VersionsManager {
pub fn new() -> Self {
Self {
manifest: RwLock::new(VersionsManifest::default()),
added: std::sync::Mutex::new(Vec::new()),
}
}
@ -28,6 +32,7 @@ impl VersionsManager {
Self {
manifest: RwLock::new(manifest),
added: std::sync::Mutex::new(Vec::new()),
}
}
@ -49,7 +54,20 @@ impl VersionsManager {
tracing::debug!("skipping versions sync; no remote or cached manifest available");
return Ok(false);
};
*self.manifest.write().await = manifest;
let mut current = self.manifest.write().await;
let added = added_release_targets(&current, &manifest);
*current = manifest;
drop(current);
if !added.is_empty() {
tracing::info!(added = ?added, "versions manifest gained new versions");
let mut pending = self.added.lock().unwrap();
for target in added {
if !pending.contains(&target) {
pending.push(target);
}
}
}
Ok(changed)
}
@ -61,6 +79,14 @@ impl VersionsManager {
self.manifest.read().await.migrations.clone()
}
pub async fn shows_initial_migration(&self, target: &ReleaseTarget) -> bool {
self.manifest.read().await.shows_initial_migration(target)
}
pub fn take_added_versions(&self) -> Vec<ReleaseTarget> {
std::mem::take(&mut *self.added.lock().unwrap())
}
#[tracing::instrument(level = "debug", skip(services))]
async fn fetch_manifest(
services: &LauncherServices,

View file

@ -43,6 +43,14 @@ pub struct RemoteEntry {
pub long_description: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub flags: EntryFlags,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntryFlags {
#[serde(default)]
pub show_initial_migration: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -63,6 +71,93 @@ pub struct MigrationTarget {
pub mc_version: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReleaseTarget {
pub mc_version: String,
pub loader: GameLoader,
}
impl ReleaseTarget {
#[must_use]
pub fn key(&self) -> String {
format!("{}:{}", self.mc_version, self.loader.modrinth_name())
}
#[must_use]
pub fn from_key(key: &str) -> Option<Self> {
let (mc_version, loader) = key.rsplit_once(':')?;
if mc_version.is_empty() {
return None;
}
Some(Self {
mc_version: mc_version.to_string(),
loader: GameLoader::from_str(loader).ok()?,
})
}
}
fn release_targets(manifest: &VersionsManifest) -> Vec<ReleaseTarget> {
manifest
.clusters
.iter()
.flat_map(|cluster| {
cluster.entries.iter().filter_map(move |entry| {
Some(ReleaseTarget {
mc_version: format_mc_version(
cluster.major_version,
entry.minor_version,
entry.patch_version,
),
loader: GameLoader::from_str(entry.loader.as_deref()?).ok()?,
})
})
})
.collect()
}
impl VersionsManifest {
#[must_use]
pub fn shows_initial_migration(&self, target: &ReleaseTarget) -> bool {
self.clusters
.iter()
.flat_map(|cluster| {
cluster
.entries
.iter()
.map(move |entry| (cluster.major_version, entry))
})
.find(|(major, entry)| {
format_mc_version(*major, entry.minor_version, entry.patch_version)
== target.mc_version
&& entry
.loader
.as_deref()
.and_then(|loader| GameLoader::from_str(loader).ok())
== Some(target.loader)
})
.and_then(|(_, entry)| entry.flags.show_initial_migration)
.unwrap_or(true)
}
}
#[must_use]
pub fn added_release_targets(
previous: &VersionsManifest,
next: &VersionsManifest,
) -> Vec<ReleaseTarget> {
if previous.clusters.is_empty() {
return Vec::new();
}
let known = release_targets(previous);
let mut added: Vec<ReleaseTarget> = Vec::new();
for target in release_targets(next) {
if !known.contains(&target) && !added.contains(&target) {
added.push(target);
}
}
added
}
/// Returns the input unchanged when no rule matches
/// Multi-hop chains are
/// followed the loop is bounded by rule count so cyclic rules can't spin forever
@ -179,6 +274,108 @@ mod tests {
assert!(manifest.migrations.is_empty());
}
fn versions(entries: &str) -> VersionsManifest {
serde_json::from_str(&format!(
r#"{{"clusters":[{{"major_version":26,"entries":[{entries}]}}]}}"#
))
.expect("should parse")
}
#[test]
fn a_version_missing_from_the_previous_manifest_is_added() {
let previous = versions(r#"{"minor_version":2,"loader":"fabric"}"#);
let next = versions(
r#"{"minor_version":2,"loader":"fabric"},{"minor_version":3,"loader":"fabric"}"#,
);
assert_eq!(
added_release_targets(&previous, &next),
vec![ReleaseTarget {
mc_version: "26.3".into(),
loader: GameLoader::Fabric
}]
);
}
#[test]
fn nothing_is_added_without_a_previous_manifest() {
let next = versions(r#"{"minor_version":3,"loader":"fabric"}"#);
assert!(
added_release_targets(&VersionsManifest::default(), &next).is_empty(),
"a fresh install has no cached manifest and must not see every version as new"
);
}
#[test]
fn a_new_loader_for_a_known_version_is_added() {
let previous = versions(r#"{"minor_version":3,"loader":"fabric"}"#);
let next = versions(
r#"{"minor_version":3,"loader":"fabric"},{"minor_version":3,"loader":"neoforge"}"#,
);
assert_eq!(
added_release_targets(&previous, &next),
vec![ReleaseTarget {
mc_version: "26.3".into(),
loader: GameLoader::NeoForge
}]
);
}
#[test]
fn show_initial_migration_reads_the_entry_flag() {
let manifest: VersionsManifest = serde_json::from_str(
r#"{"clusters":[{"major_version":8,"entries":[
{"minor_version":9,"loader":"ornithe","flags":{"show_initial_migration":false}}
]},{"major_version":26,"entries":[
{"minor_version":3,"loader":"fabric"},
{"minor_version":4,"loader":"fabric","flags":{}}
]}]}"#,
)
.expect("should parse");
let target = |mc_version: &str, loader| ReleaseTarget {
mc_version: mc_version.into(),
loader,
};
assert!(!manifest.shows_initial_migration(&target("1.8.9", GameLoader::Ornithe)));
assert!(
manifest.shows_initial_migration(&target("26.3", GameLoader::Fabric)),
"no flags means show"
);
assert!(
manifest.shows_initial_migration(&target("26.4", GameLoader::Fabric)),
"an empty flags object means show"
);
assert!(
manifest.shows_initial_migration(&target("1.8.9", GameLoader::Fabric)),
"the flag belongs to its own loader"
);
}
#[test]
fn unknown_flags_are_ignored() {
let manifest: VersionsManifest = serde_json::from_str(
r#"{"clusters":[{"major_version":26,"entries":[
{"minor_version":3,"loader":"fabric","flags":{"something_new":true}}
]}]}"#,
)
.expect("an unknown flag must not break parsing");
assert_eq!(
manifest.clusters[0].entries[0].flags.show_initial_migration,
None
);
}
#[test]
fn release_target_keys_round_trip() {
let target = ReleaseTarget {
mc_version: "1.8.9".into(),
loader: GameLoader::Fabric,
};
assert_eq!(target.key(), "1.8.9:fabric");
assert_eq!(ReleaseTarget::from_key("1.8.9:fabric"), Some(target));
assert_eq!(ReleaseTarget::from_key("garbage"), None);
}
#[test]
fn entry_without_patch_key_parses() {
let manifest: VersionsManifest = serde_json::from_str(

View file

@ -3,6 +3,6 @@ mod manifest;
pub use manager::VersionsManager;
pub use manifest::{
MigrationSource, MigrationTarget, RemoteMigration, VersionMetadata, VersionsManifest,
resolve_migration_chain,
MigrationSource, MigrationTarget, ReleaseTarget, RemoteMigration, VersionMetadata,
VersionsManifest, added_release_targets, resolve_migration_chain,
};

View file

@ -0,0 +1,13 @@
CREATE TABLE release_migration_waitlist (
target_cluster_id INTEGER NOT NULL,
provider INTEGER NOT NULL,
project_id TEXT NOT NULL,
content_type INTEGER NOT NULL,
source_hash TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
added_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
PRIMARY KEY (target_cluster_id, provider, project_id),
FOREIGN KEY (target_cluster_id) REFERENCES clusters (id) ON DELETE CASCADE
);
CREATE INDEX release_migration_waitlist_expires_at_idx ON release_migration_waitlist (expires_at);

View file

@ -0,0 +1 @@
ALTER TABLE release_migration_waitlist ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;

View file

@ -8,4 +8,5 @@ pub mod cluster_optional_mod;
pub mod game_session;
pub mod java;
pub mod package_metadata;
pub mod release_migration_waitlist;
pub mod setting_profile;

View file

@ -0,0 +1,88 @@
use sqlx::SqlitePool;
use crate::models::ReleaseMigrationWaitlistRow;
#[allow(clippy::too_many_arguments)]
pub async fn upsert(
pool: &SqlitePool,
target_cluster_id: i64,
provider: i64,
project_id: &str,
content_type: i64,
source_hash: &str,
display_name: &str,
added_at: &str,
expires_at: &str,
enabled: bool,
) -> Result<(), sqlx::Error> {
let enabled = i64::from(enabled);
sqlx::query!(
r"
INSERT INTO release_migration_waitlist (
target_cluster_id, provider, project_id, content_type,
source_hash, display_name, added_at, expires_at, enabled
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(target_cluster_id, provider, project_id) DO UPDATE SET
content_type = excluded.content_type,
source_hash = excluded.source_hash,
display_name = excluded.display_name,
added_at = excluded.added_at,
expires_at = excluded.expires_at,
enabled = excluded.enabled
",
target_cluster_id,
provider,
project_id,
content_type,
source_hash,
display_name,
added_at,
expires_at,
enabled
)
.execute(pool)
.await?;
Ok(())
}
pub async fn list_all(pool: &SqlitePool) -> Result<Vec<ReleaseMigrationWaitlistRow>, sqlx::Error> {
sqlx::query_as!(
ReleaseMigrationWaitlistRow,
r"
SELECT
target_cluster_id, provider, project_id, content_type,
source_hash, display_name, expires_at, enabled
FROM release_migration_waitlist
",
)
.fetch_all(pool)
.await
}
pub async fn delete(
pool: &SqlitePool,
target_cluster_id: i64,
provider: i64,
project_id: &str,
) -> Result<(), sqlx::Error> {
sqlx::query!(
"DELETE FROM release_migration_waitlist WHERE target_cluster_id = ? AND provider = ? AND project_id = ?",
target_cluster_id,
provider,
project_id
)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete_expired(pool: &SqlitePool, now: &str) -> Result<u64, sqlx::Error> {
let result = sqlx::query!(
"DELETE FROM release_migration_waitlist WHERE expires_at <= ?",
now
)
.execute(pool)
.await?;
Ok(result.rows_affected())
}

View file

@ -7,6 +7,7 @@ mod cluster_optional_mod;
mod game_session;
mod java;
mod package_metadata;
mod release_migration_waitlist;
mod setting_profile;
pub use artifact::{
@ -24,4 +25,5 @@ pub use game_session::{
};
pub use java::JavaVersionRow;
pub use package_metadata::PackageMetadataRow;
pub use release_migration_waitlist::ReleaseMigrationWaitlistRow;
pub use setting_profile::SettingProfileRow;

View file

@ -0,0 +1,13 @@
use sqlx::FromRow;
#[derive(Debug, Clone, FromRow)]
pub struct ReleaseMigrationWaitlistRow {
pub target_cluster_id: i64,
pub provider: i64,
pub project_id: String,
pub content_type: i64,
pub source_hash: String,
pub display_name: String,
pub expires_at: String,
pub enabled: i64,
}