From 45da20fac2adc015f6bec7ada5d20753ca169e6a Mon Sep 17 00:00:00 2001 From: Maciek <108873945+rozwader@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:06:30 +0200 Subject: [PATCH] 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> --- ...8edd855deaeb31e52209ae436680962dea954.json | 110 ++ ...5fb64515cf1d7c28bc6a1bf1fb1645fdba2a1.json | 12 + ...bca6fe5fbc1256b3537ed37195ed20075f944.json | 12 + ...1d22e475471578b5ef73927e5427c2c0abc77.json | 12 + .../oneclient_app/src/components/dropdown.rs | 18 +- packages/oneclient_app/src/components/mod.rs | 2 + .../src/components/release_migration_popup.rs | 1348 +++++++++++++++++ packages/oneclient_app/src/hooks/actions.rs | 12 +- .../src/hooks/actions/release_migration.rs | 821 ++++++++++ packages/oneclient_app/src/hooks/mod.rs | 18 +- .../oneclient_app/src/layout/root_layout.rs | 5 +- packages/oneclient_app/src/state.rs | 51 + .../src/view/app/cluster/cluster_settings.rs | 92 +- packages/oneclient_app/src/view/app/debug.rs | 123 ++ .../oneclient_content/src/bundles/install.rs | 34 + packages/oneclient_content/src/bundles/mod.rs | 12 +- .../src/packages/dependencies.rs | 2 +- .../oneclient_content/src/packages/mod.rs | 1 + .../src/packages/provider/curseforge.rs | 124 +- .../src/packages/provider/mod.rs | 12 +- .../src/packages/provider/modrinth.rs | 102 +- .../src/packages/release_migration.rs | 990 ++++++++++++ .../oneclient_content/src/packages/types.rs | 7 + .../oneclient_content/src/packages/updates.rs | 55 +- packages/oneclient_core/src/clusters/mod.rs | 5 + .../src/clusters/release_migration.rs | 301 ++++ packages/oneclient_core/src/lib.rs | 3 +- .../oneclient_core/src/settings/launcher.rs | 2 + packages/oneclient_core/src/state.rs | 3 + .../oneclient_core/src/versions/manager.rs | 30 +- .../oneclient_core/src/versions/manifest.rs | 197 +++ packages/oneclient_core/src/versions/mod.rs | 4 +- ...60918120000_release_migration_waitlist.sql | 13 + ...000_release_migration_waitlist_enabled.sql | 1 + packages/oneclient_db/src/dao/mod.rs | 1 + .../src/dao/release_migration_waitlist.rs | 88 ++ packages/oneclient_db/src/models/mod.rs | 2 + .../src/models/release_migration_waitlist.rs | 13 + 38 files changed, 4587 insertions(+), 51 deletions(-) create mode 100644 .sqlx/query-15dc690e91fe0bc7e1932a5051f8edd855deaeb31e52209ae436680962dea954.json create mode 100644 .sqlx/query-1e43f93b56bb495e13826c734fe5fb64515cf1d7c28bc6a1bf1fb1645fdba2a1.json create mode 100644 .sqlx/query-e12409b04c8a3eea09a4bfc1014bca6fe5fbc1256b3537ed37195ed20075f944.json create mode 100644 .sqlx/query-ff07b78c0e97f1f38a6900092391d22e475471578b5ef73927e5427c2c0abc77.json create mode 100644 packages/oneclient_app/src/components/release_migration_popup.rs create mode 100644 packages/oneclient_app/src/hooks/actions/release_migration.rs create mode 100644 packages/oneclient_content/src/packages/release_migration.rs create mode 100644 packages/oneclient_core/src/clusters/release_migration.rs create mode 100644 packages/oneclient_db/migrations/20260918120000_release_migration_waitlist.sql create mode 100644 packages/oneclient_db/migrations/20260918130000_release_migration_waitlist_enabled.sql create mode 100644 packages/oneclient_db/src/dao/release_migration_waitlist.rs create mode 100644 packages/oneclient_db/src/models/release_migration_waitlist.rs diff --git a/.sqlx/query-15dc690e91fe0bc7e1932a5051f8edd855deaeb31e52209ae436680962dea954.json b/.sqlx/query-15dc690e91fe0bc7e1932a5051f8edd855deaeb31e52209ae436680962dea954.json new file mode 100644 index 00000000..b3a70c7b --- /dev/null +++ b/.sqlx/query-15dc690e91fe0bc7e1932a5051f8edd855deaeb31e52209ae436680962dea954.json @@ -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" +} diff --git a/.sqlx/query-1e43f93b56bb495e13826c734fe5fb64515cf1d7c28bc6a1bf1fb1645fdba2a1.json b/.sqlx/query-1e43f93b56bb495e13826c734fe5fb64515cf1d7c28bc6a1bf1fb1645fdba2a1.json new file mode 100644 index 00000000..d9bb3c9e --- /dev/null +++ b/.sqlx/query-1e43f93b56bb495e13826c734fe5fb64515cf1d7c28bc6a1bf1fb1645fdba2a1.json @@ -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" +} diff --git a/.sqlx/query-e12409b04c8a3eea09a4bfc1014bca6fe5fbc1256b3537ed37195ed20075f944.json b/.sqlx/query-e12409b04c8a3eea09a4bfc1014bca6fe5fbc1256b3537ed37195ed20075f944.json new file mode 100644 index 00000000..218e1200 --- /dev/null +++ b/.sqlx/query-e12409b04c8a3eea09a4bfc1014bca6fe5fbc1256b3537ed37195ed20075f944.json @@ -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" +} diff --git a/.sqlx/query-ff07b78c0e97f1f38a6900092391d22e475471578b5ef73927e5427c2c0abc77.json b/.sqlx/query-ff07b78c0e97f1f38a6900092391d22e475471578b5ef73927e5427c2c0abc77.json new file mode 100644 index 00000000..e553b21b --- /dev/null +++ b/.sqlx/query-ff07b78c0e97f1f38a6900092391d22e475471578b5ef73927e5427c2c0abc77.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM release_migration_waitlist WHERE expires_at <= ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "ff07b78c0e97f1f38a6900092391d22e475471578b5ef73927e5427c2c0abc77" +} diff --git a/packages/oneclient_app/src/components/dropdown.rs b/packages/oneclient_app/src/components/dropdown.rs index a8541e49..0c0c11ee 100644 --- a/packages/oneclient_app/src/components/dropdown.rs +++ b/packages/oneclient_app/src/components/dropdown.rs @@ -17,6 +17,7 @@ pub struct Dropdown { leading: Option, 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( diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 6b876220..486f7e22 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -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}; diff --git a/packages/oneclient_app/src/components/release_migration_popup.rs b/packages/oneclient_app/src/components/release_migration_popup.rs new file mode 100644 index 00000000..e40cd147 --- /dev/null +++ b/packages/oneclient_app/src/components/release_migration_popup.rs @@ -0,0 +1,1348 @@ +use std::collections::{HashMap, HashSet}; + +use freya::prelude::*; +use freya::router::use_route; +use oneclient_common::domain::{ContentType, GameLoader}; +use oneclient_common::version::parse_mc_version; +use oneclient_content::packages::release_migration::{ + ReleaseMigrationDependency, ReleaseMigrationPackage, ReleaseMigrationPlan, + ReleaseMigrationSkip, SkipReason, WAITLIST_DAYS, +}; +use oneclient_content::packages::{CachedPackageMeta, ProviderId}; +use oneclient_core::clusters::Cluster; + +use crate::components::{Button, Dropdown, DynamicArt, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::hooks::{ + loaded_image, package_meta_batch, use_cached_image, use_dispatch, use_game_snapshot, + use_launcher, use_notifications_snapshot, use_package_meta_batch, use_release_migration, +}; +use crate::routes::Route; +use crate::state::{ReleaseMigrationPrompt, ReleasePlanState}; +use crate::theme::colors; +use crate::ui::{ImageFallbackExt, border_all_color}; +use crate::utils::format_size; + +const DIALOG_W: f32 = 1020.; +const DIALOG_H: f32 = 616.; +const ART_W: f32 = 368.; +const PANEL_BG: Color = Color::from_rgb(22, 28, 35); +const FOOTER_BG: Color = Color::from_rgb(19, 25, 31); +const CARD_BG: Color = Color::from_argb(214, 17, 22, 28); +const ROW_ICON: f32 = 32.; +const CHECK_SIZE: f32 = 24.; +const SKIPPED_ICON: f32 = 20.; +const SKIPPED_NAME_W: f32 = 168.; +const REQUIRED_BY_W: f32 = 190.; + +type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; +type SelectionKey = (i64, String); + +#[derive(Clone, Copy, PartialEq, Eq)] +enum PackageTab { + Mods, + ResourcePacks, + Shaders, +} + +impl PackageTab { + const ALL: [Self; 3] = [Self::Mods, Self::ResourcePacks, Self::Shaders]; + + fn label(self) -> &'static str { + match self { + Self::Mods => "Mods", + Self::ResourcePacks => "Resource packs", + Self::Shaders => "Shaders", + } + } + + fn content_type(self) -> ContentType { + match self { + Self::Mods => ContentType::Mod, + Self::ResourcePacks => ContentType::ResourcePack, + Self::Shaders => ContentType::Shader, + } + } +} + +fn blocked_by_route(route: &Route) -> bool { + matches!( + route, + Route::Startup {} + | Route::Relocating {} + | Route::OnboardingWelcome {} + | Route::OnboardingLocation {} + | Route::OnboardingTerms {} + | Route::OnboardingMigration {} + | Route::OnboardingLanguage {} + | Route::OnboardingAccount {} + | Route::OnboardingPreferences {} + | Route::OnboardingBundles {} + | Route::OnboardingSummary {} + ) +} + +fn cluster_title(cluster: &Cluster) -> String { + cluster.name.clone() +} + +fn selection_key(package: &ReleaseMigrationPackage, source_id: i64) -> SelectionKey { + (source_id, package.source_hash.clone()) +} + +#[derive(PartialEq)] +pub struct ReleaseMigrationPopup; + +impl Component for ReleaseMigrationPopup { + fn render(&self) -> impl IntoElement { + let launcher = use_launcher(); + let dispatch = use_dispatch(); + let route = use_route::(); + let snapshot = use_notifications_snapshot(); + let game = use_game_snapshot(); + let prompt = use_release_migration(); + + let mut excluded = use_state(HashSet::::new); + let mut tab = use_state(|| PackageTab::Mods); + + let settled = launcher.ready && !launcher.fetching && !launcher.syncing_bundles; + let checked = use_state(|| false); + let check_dispatch = dispatch.clone(); + use_side_effect_with_deps(&settled, move |&settled| { + let mut checked = checked; + if settled && !*checked.peek() { + checked.set(true); + check_dispatch.check_release_migration(); + check_dispatch.process_release_waitlist(); + } + }); + + let mut wont_open = use_state(|| true); + let mut deps_open = use_state(|| false); + + let projects: Vec<(ProviderId, String)> = + prompt + .as_ref() + .and_then(|prompt| match prompt.plan() { + Some(ReleasePlanState::Ready(plan)) => Some( + plan.packages + .iter() + .map(|package| (package.provider, package.project_id.clone())) + .chain( + plan.unavailable + .iter() + .map(|skip| (skip.provider, skip.project_id.clone())), + ) + .chain(plan.dependencies.iter().map(|dependency| { + (dependency.provider, dependency.project.id.clone()) + })) + .collect(), + ), + _ => None, + }) + .unwrap_or_default(); + + let mut meta = MetaMap::new(); + for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { + let ids: Vec = projects + .iter() + .filter(|(owner, _)| *owner == provider) + .map(|(_, project_id)| project_id.clone()) + .collect(); + let query = use_package_meta_batch(provider, ids); + for (project_id, cached) in package_meta_batch(&query) { + meta.insert((provider, project_id), cached); + } + } + + let Some(prompt) = prompt else { + excluded.set_if_modified(HashSet::new()); + tab.set_if_modified(PackageTab::Mods); + wont_open.set_if_modified(true); + deps_open.set_if_modified(false); + return rect().into_element(); + }; + + let other_popup = snapshot.cluster_update.is_some() + || snapshot.optional_mods.is_some() + || snapshot.package_updates.is_some() + || snapshot.pending_prompt.is_some(); + if blocked_by_route(&route) || other_popup { + return rect().into_element(); + } + + let target_running = game.is_running(prompt.target.id); + let close = dispatch.clone(); + let panel_dispatch = dispatch.clone(); + + OverlayPopup::new() + .on_close(move |_| close.dismiss_release_migration()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(dialog( + &prompt, + &meta, + panel_dispatch, + excluded, + tab, + wont_open, + deps_open, + target_running, + )), + ) + .into_element() + } +} + +#[allow(clippy::too_many_arguments)] +fn dialog( + prompt: &ReleaseMigrationPrompt, + meta: &MetaMap, + dispatch: crate::Actions, + excluded: State>, + tab: State, + wont_open: State, + deps_open: State, + target_running: bool, +) -> impl IntoElement { + let plan = match prompt.plan() { + Some(ReleasePlanState::Ready(plan)) => Some(plan), + _ => None, + }; + + rect() + .horizontal() + .width(Size::px(DIALOG_W)) + .height(Size::px(DIALOG_H)) + .max_width(Size::window_percent(95.)) + .max_height(Size::window_percent(92.)) + .overflow(Overflow::Clip) + .corner_radius(CornerRadius::new_all(18.)) + .background(PANEL_BG) + .border(border_all_color(1., colors::component_border())) + .shadow(Shadow::from(( + 0., + 24., + 64., + 0., + Color::from_argb(160, 0, 0, 0), + ))) + .content(Content::Flex) + .child(art_panel(prompt, plan, &excluded.read())) + .child(content_panel( + prompt, + plan, + meta, + dispatch, + excluded, + tab, + wont_open, + deps_open, + target_running, + )) +} + +fn art_panel( + prompt: &ReleaseMigrationPrompt, + plan: Option<&ReleaseMigrationPlan>, + excluded: &HashSet, +) -> impl IntoElement { + let target = &prompt.target; + let parsed = parse_mc_version(&target.mc_version); + let art = parsed + .map(|parsed| DynamicArt::for_version(parsed.major, parsed.key(), Some(target.mc_loader))); + + let mut runtime = Vec::new(); + if target.mc_loader != GameLoader::Vanilla { + runtime.push(match target.mc_loader_version.as_deref() { + Some(version) if !version.is_empty() => format!("{} {version}", target.mc_loader), + _ => target.mc_loader.to_string(), + }); + } + if let Some(java) = prompt.java_major { + runtime.push(format!("Java {java}")); + } + + rect() + .width(Size::px(ART_W)) + .height(Size::fill()) + .overflow(Overflow::Clip) + .background(colors::page()) + .maybe_child(art.map(|art| { + rect() + .position(Position::new_absolute()) + .width(Size::fill()) + .height(Size::fill()) + .layer(Layer::Relative(1)) + .child(art) + })) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .padding(Gaps::new(24., 24., 24., 24.)) + .main_align(Alignment::End) + .layer(Layer::Relative(3)) + .background( + LinearGradient::new() + .angle(0.) + .stop((Color::from_af32rgb(0.0, 13, 17, 21), 28.)) + .stop((Color::from_af32rgb(0.85, 13, 17, 21), 58.)) + .stop((Color::from_af32rgb(0.98, 13, 17, 21), 100.)), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .child( + label() + .text(cluster_title(target)) + .font_size(32.) + .font_weight(FontWeight::BOLD) + .max_lines(1) + .color(Color::WHITE), + ) + .maybe_child((!runtime.is_empty()).then(|| { + label() + .text(runtime.join(" · ")) + .font_size(13.) + .margin(Gaps::new(12., 0., 0., 0.)) + .color(colors::fg_primary()) + })) + .child(summary_card(plan, excluded)), + ), + ) +} + +fn summary_card( + plan: Option<&ReleaseMigrationPlan>, + excluded: &HashSet, +) -> impl IntoElement { + let mut rows = rect().vertical().width(Size::fill()).spacing(12.); + for tab in PackageTab::ALL { + let value = plan.map_or_else( + || "–".to_string(), + |plan| { + let dependencies = if tab.content_type() == ContentType::Mod { + plan.dependencies.len() + } else { + 0 + }; + let needed = if tab.content_type() == ContentType::Mod { + let chosen = selected_hashes(plan, excluded); + plan.dependencies + .iter() + .filter(|dependency| dependency.is_needed_by(&chosen)) + .count() + } else { + 0 + }; + + let total = plan.offered(tab.content_type()) + + dependencies + + plan + .unavailable + .iter() + .filter(|skip| skip.content_type == tab.content_type()) + .count(); + let selected = needed + + plan + .packages + .iter() + .filter(|package| package.content_type == tab.content_type()) + .filter(|package| { + !excluded.contains(&selection_key(package, plan.source_cluster_id)) + }) + .count(); + format!("{selected} of {total}") + }, + ); + rows = rows.child(summary_row(tab.label(), value, colors::fg_primary())); + } + + let wont = plan.map_or_else( + || "–".to_string(), + |plan| plan.unavailable.len().to_string(), + ); + + rect() + .vertical() + .width(Size::fill()) + .margin(Gaps::new(20., 0., 0., 0.)) + .padding(Gaps::new(16., 16., 16., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child(rows) + .child( + rect() + .width(Size::fill()) + .height(Size::px(1.)) + .margin(Gaps::new(14., 0., 14., 0.)) + .background(colors::component_border()), + ) + .child(summary_row("Won't migrate", wont, colors::fg_secondary())) +} + +fn summary_row(name: &str, value: String, name_color: Color) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .child( + label() + .text(name.to_string()) + .font_size(13.) + .width(Size::flex(1.)) + .color(name_color), + ) + .child( + label() + .text(value) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) +} + +#[allow(clippy::too_many_arguments)] +fn content_panel( + prompt: &ReleaseMigrationPrompt, + plan: Option<&ReleaseMigrationPlan>, + meta: &MetaMap, + dispatch: crate::Actions, + excluded: State>, + tab: State, + wont_open: State, + deps_open: State, + target_running: bool, +) -> impl IntoElement { + let close = dispatch.clone(); + let picker_dispatch = dispatch.clone(); + let source_title = prompt.source().map(cluster_title).unwrap_or_default(); + + let subtitle = format!( + "Files are copied into {}. Your {source_title} cluster is left exactly as it is.", + prompt.target.mc_version + ); + + rect() + .vertical() + .width(Size::flex(1.)) + .height(Size::fill()) + .content(Content::Flex) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.)) + .content(Content::Flex) + .padding(Gaps::new(22., 24., 0., 24.)) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .child( + label() + .text("Migrate your packages") + .font_size(21.) + .font_weight(FontWeight::SEMI_BOLD) + .width(Size::flex(1.)) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + Button::new() + .ghost() + .icon() + .on_press(move |_| close.dismiss_release_migration()) + .child( + Icon::new(IconType::XClose) + .size(16.) + .color(colors::fg_secondary()), + ), + ), + ) + .child( + label() + .text(subtitle) + .font_size(13.) + .max_lines(2) + .margin(Gaps::new(4., 0., 0., 0.)) + .color(colors::fg_secondary()), + ) + .child(source_picker(prompt, picker_dispatch)) + .child(tab_row(plan, excluded, tab)) + .child(package_list( + prompt, plan, meta, excluded, tab, wont_open, deps_open, + )), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::px(1.)) + .background(colors::component_border()), + ) + .child(footer(prompt, plan, dispatch, excluded, target_running)) +} + +fn source_picker(prompt: &ReleaseMigrationPrompt, dispatch: crate::Actions) -> impl IntoElement { + let sources: Vec = prompt.sources.iter().map(|cluster| cluster.id).collect(); + let options: Vec = prompt.sources.iter().map(cluster_title).collect(); + let selected = prompt.source().map(cluster_title).unwrap_or_default(); + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(12.) + .margin(Gaps::new(18., 0., 0., 0.)) + .child( + label() + .text("Migrate from") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + Dropdown::new(selected, options) + .outlined() + .width(Size::px(212.)) + .height(Size::px(32.)) + .on_select(move |index: usize| { + if let Some(source_id) = sources.get(index) { + dispatch.select_release_migration_source(*source_id); + } + }), + ) +} + +fn tab_row( + plan: Option<&ReleaseMigrationPlan>, + excluded: State>, + tab: State, +) -> impl IntoElement { + let active = *tab.read(); + + let mut pills = rect() + .horizontal() + .spacing(8.) + .cross_align(Alignment::Center); + for option in PackageTab::ALL { + let count = plan.map_or(0, |plan| plan.offered(option.content_type())); + let mut tab = tab; + pills = pills.child(tab_pill( + format!("{} · {count}", option.label()), + option == active, + move || tab.set(option), + )); + } + + let all_keys: Vec = plan + .map(|plan| { + plan.packages + .iter() + .map(|package| selection_key(package, plan.source_cluster_id)) + .collect() + }) + .unwrap_or_default(); + let any_selected = all_keys.iter().any(|key| !excluded.read().contains(key)); + let has_packages = !all_keys.is_empty(); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .margin(Gaps::new(18., 0., 10., 0.)) + .child(rect().width(Size::flex(1.)).child(pills)) + .maybe_child(has_packages.then(|| { + let mut excluded = excluded; + rect() + .padding(Gaps::new_symmetric(6., 4.)) + .cursor(CursorIcon::Pointer) + .on_press(move |_| { + let mut set = excluded.write(); + if any_selected { + set.extend(all_keys.iter().cloned()); + } else { + for key in &all_keys { + set.remove(key); + } + } + }) + .child( + label() + .text(if any_selected { + "Clear all" + } else { + "Select all" + }) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + })) +} + +fn tab_pill(text: String, active: bool, mut on_press: impl FnMut() + 'static) -> impl IntoElement { + rect() + .height(Size::px(34.)) + .padding(Gaps::new_symmetric(0., 13.)) + .center() + .corner_radius(CornerRadius::new_all(8.)) + .background(if active { + colors::brand() + } else { + Color::TRANSPARENT + }) + .border(border_all_color( + 1., + if active { + colors::brand() + } else { + colors::component_border() + }, + )) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_press()) + .child( + label() + .text(text) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(if active { + Color::WHITE + } else { + colors::fg_primary() + }), + ) +} + +fn package_list( + prompt: &ReleaseMigrationPrompt, + plan: Option<&ReleaseMigrationPlan>, + meta: &MetaMap, + excluded: State>, + tab: State, + wont_open: State, + deps_open: State, +) -> impl IntoElement { + let source_title = prompt.source().map(cluster_title).unwrap_or_default(); + + let message = match (prompt.plan(), plan) { + (Some(ReleasePlanState::Loading) | None, _) => { + Some("Checking which packages have a compatible version…".to_string()) + } + (Some(ReleasePlanState::Failed), _) => Some( + "Couldn't reach Modrinth or CurseForge. Check your connection and try again." + .to_string(), + ), + _ => None, + }; + if let Some(message) = message { + return list_message(message).into_element(); + } + + let Some(plan) = plan else { + return rect().into_element(); + }; + + let content_type = tab.read().content_type(); + let rows: Vec<&ReleaseMigrationPackage> = plan + .packages + .iter() + .filter(|package| package.content_type == content_type) + .collect(); + + let skipped: Vec<&ReleaseMigrationSkip> = plan + .unavailable + .iter() + .filter(|skip| skip.content_type == content_type) + .collect(); + + let dependencies: Vec<&ReleaseMigrationDependency> = if content_type == ContentType::Mod { + plan.dependencies.iter().collect() + } else { + Vec::new() + }; + + if rows.is_empty() && skipped.is_empty() && dependencies.is_empty() { + return list_message(format!( + "Nothing from {source_title} has a {} version yet.", + prompt.target.mc_version + )) + .into_element(); + } + + let mut scroll = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.)) + .spacing(0.); + + let mut has_content = !rows.is_empty(); + for package in rows { + let key = selection_key(package, plan.source_cluster_id); + let selected = !excluded.read().contains(&key); + + scroll = scroll.child( + MigrationRow { + name: package_name( + meta, + package.provider, + &package.project_id, + &package.display_name, + ), + icon_url: meta + .get(&(package.provider, package.project_id.clone())) + .and_then(|cached| cached.icon_url.clone()), + package: package.clone(), + selected, + selection: key.clone(), + excluded, + key: DiffKey::None, + } + .key(format!("{}:{}", key.0, key.1)) + .into_element(), + ); + } + + if !dependencies.is_empty() { + let selected_hashes = selected_hashes(plan, &excluded.read()); + let open = *deps_open.read(); + scroll = scroll.child(section_header( + IconType::Link03, + format!("Dependencies · {}", dependencies.len()), + Some("Installed with the packages that need them".to_string()), + open, + deps_open, + has_content, + )); + has_content = true; + + if open { + for dependency in dependencies { + let required_by: Vec = dependency + .required_by + .iter() + .filter_map(|hash| { + plan.packages + .iter() + .find(|package| &package.source_hash == hash) + }) + .map(|package| { + package_name( + meta, + package.provider, + &package.project_id, + &package.display_name, + ) + }) + .collect(); + let cached = meta.get(&(dependency.provider, dependency.project.id.clone())); + + scroll = scroll.child( + DependencyRow { + name: package_name( + meta, + dependency.provider, + &dependency.project.id, + &dependency.project.name, + ), + icon_url: cached + .and_then(|cached| cached.icon_url.clone()) + .or_else(|| dependency.project.icon_url.clone()), + provider: dependency.provider, + version_name: dependency.version_name(), + size: dependency.size(), + required_by: format!("Required by {}", required_by.join(", ")), + needed: dependency.is_needed_by(&selected_hashes), + key: DiffKey::None, + } + .key(format!( + "dependency:{}:{}", + plan.source_cluster_id, dependency.project.id + )) + .into_element(), + ); + } + } + } + + if !skipped.is_empty() { + let cross_loader = prompt.is_cross_loader(); + let build = build_label(prompt); + let mc_version = &build; + let open = *wont_open.read(); + scroll = scroll.child(section_header( + IconType::AlertCircle, + format!("Won't migrate · {}", skipped.len()), + most_common_reason(&skipped).map(|reason| reason_text(reason, mc_version)), + open, + wont_open, + has_content, + )); + + if open { + if !cross_loader { + scroll = scroll.child( + label() + .text(format!( + "Added automatically when a {mc_version} build appears (up to {WAITLIST_DAYS} days)" + )) + .font_size(12.) + .max_lines(1) + .margin(Gaps::new(0., 12., 6., 12.)) + .color(colors::fg_secondary()), + ); + } + for skip in skipped { + let cached = meta.get(&(skip.provider, skip.project_id.clone())); + scroll = scroll.child( + SkippedRow { + name: package_name( + meta, + skip.provider, + &skip.project_id, + &skip.display_name, + ), + icon_url: cached.and_then(|cached| cached.icon_url.clone()), + reason: reason_text(skip.reason, mc_version), + key: DiffKey::None, + } + .key(format!( + "skip:{}:{}", + plan.source_cluster_id, skip.project_id + )) + .into_element(), + ); + } + } + } + + scroll.into_element() +} + +fn package_name(meta: &MetaMap, provider: ProviderId, project_id: &str, fallback: &str) -> String { + meta.get(&(provider, project_id.to_string())) + .map(|cached| cached.name.clone()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn selected_hashes( + plan: &ReleaseMigrationPlan, + excluded: &HashSet, +) -> HashSet { + plan.packages + .iter() + .filter(|package| !excluded.contains(&selection_key(package, plan.source_cluster_id))) + .map(|package| package.source_hash.clone()) + .collect() +} + +fn build_label(prompt: &ReleaseMigrationPrompt) -> String { + let mc_version = &prompt.target.mc_version; + if !prompt.is_cross_loader() { + return mc_version.clone(); + } + match prompt.target.mc_loader { + GameLoader::Ornithe => format!("Ornithe {mc_version}"), + loader => format!("{loader} {mc_version}"), + } +} + +fn reason_text(reason: SkipReason, mc_version: &str) -> String { + match reason { + SkipReason::NoCompatibleVersion => format!("No {mc_version} build yet"), + SkipReason::ProviderUnavailable => "Couldn't check its provider".to_string(), + SkipReason::MissingDependency => "Depends on a package that can't migrate".to_string(), + } +} + +fn most_common_reason(skipped: &[&ReleaseMigrationSkip]) -> Option { + let mut counts: HashMap = HashMap::new(); + for skip in skipped { + *counts.entry(skip.reason).or_default() += 1; + } + [ + SkipReason::NoCompatibleVersion, + SkipReason::MissingDependency, + SkipReason::ProviderUnavailable, + ] + .into_iter() + .filter(|reason| counts.contains_key(reason)) + .max_by_key(|reason| counts[reason]) +} + +fn section_header( + icon: IconType, + title: String, + subtitle: Option, + open: bool, + mut state: State, + after_content: bool, +) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(44.)) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .margin(Gaps::new(if after_content { 10. } else { 0. }, 0., 6., 0.)) + .padding(Gaps::new_symmetric(0., 12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::ghost_overlay()) + .border(border_all_color(1., colors::component_border()).alignment(BorderAlignment::Inner)) + .cursor(CursorIcon::Pointer) + .on_press(move |_| state.toggle()) + .child(Icon::new(icon).size(16.).color(colors::fg_secondary())) + .child( + label() + .text(title) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle.unwrap_or_default()) + .font_size(12.) + .width(Size::flex(1.)) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .child( + Icon::new(if open { + IconType::ChevronDown + } else { + IconType::ChevronRight + }) + .size(16.) + .color(colors::fg_secondary()), + ) +} + +#[derive(PartialEq)] +struct DependencyRow { + name: String, + icon_url: Option, + provider: ProviderId, + version_name: String, + size: u64, + required_by: String, + needed: bool, + key: DiffKey, +} + +impl KeyExt for DependencyRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for DependencyRow { + fn render(&self) -> impl IntoElement { + let icon_query = use_cached_image(self.icon_url.clone(), 96); + let icon = match loaded_image(self.icon_url.as_deref(), &icon_query) { + Some((url, bytes)) => rect() + .width(Size::px(ROW_ICON)) + .height(Size::px(ROW_ICON)) + .corner_radius(CornerRadius::new_all(8.)) + .overflow(Overflow::Clip) + .child( + ImageViewer::new((url, bytes)) + .width(Size::px(ROW_ICON)) + .height(Size::px(ROW_ICON)) + .aspect_ratio(AspectRatio::Min) + .fallback(placeholder_icon()), + ) + .into_element(), + None => placeholder_icon().into_element(), + }; + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(56.)) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(14.) + .padding(Gaps::new_symmetric(0., 12.)) + .opacity(if self.needed { 1. } else { 0.45 }) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.)) + .spacing(2.) + .child( + label() + .text(self.name.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!( + "{} · {}", + provider_label(self.provider), + self.version_name + )) + .font_size(12.) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_secondary()), + ), + ) + .maybe_child((self.size > 0).then(|| { + label() + .text(format_size(self.size)) + .font_size(12.) + .color(colors::fg_secondary()) + })) + .child( + label() + .text(self.required_by.clone()) + .font_size(12.) + .width(Size::px(REQUIRED_BY_W)) + .max_lines(1) + .text_align(TextAlign::End) + .color(colors::fg_secondary()), + ) + } +} + +fn provider_label(provider: ProviderId) -> &'static str { + match provider { + ProviderId::Modrinth => "Modrinth", + ProviderId::CurseForge => "CurseForge", + ProviderId::Local => "Local", + } +} + +#[derive(PartialEq)] +struct SkippedRow { + name: String, + icon_url: Option, + reason: String, + key: DiffKey, +} + +impl KeyExt for SkippedRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for SkippedRow { + fn render(&self) -> impl IntoElement { + let icon_query = use_cached_image(self.icon_url.clone(), 96); + let icon = match loaded_image(self.icon_url.as_deref(), &icon_query) { + Some((url, bytes)) => rect() + .width(Size::px(SKIPPED_ICON)) + .height(Size::px(SKIPPED_ICON)) + .corner_radius(CornerRadius::new_all(6.)) + .overflow(Overflow::Clip) + .child( + ImageViewer::new((url, bytes)) + .width(Size::px(SKIPPED_ICON)) + .height(Size::px(SKIPPED_ICON)) + .aspect_ratio(AspectRatio::Min), + ) + .into_element(), + None => Icon::new(IconType::DotsGrid) + .size(16.) + .color(colors::fg_secondary()) + .into_element(), + }; + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(40.)) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(14.) + .padding(Gaps::new_symmetric(0., 12.)) + .opacity(0.6) + .child(rect().width(Size::px(ROW_ICON)).center().child(icon)) + .child( + label() + .text(self.name.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .width(Size::px(SKIPPED_NAME_W)) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(self.reason.clone()) + .font_size(12.) + .width(Size::flex(1.)) + .max_lines(1) + .color(colors::fg_secondary()), + ) + } +} + +fn list_message(text: String) -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::flex(1.)) + .center() + .child( + label() + .text(text) + .font_size(13.) + .color(colors::fg_secondary()), + ) +} + +fn footer( + prompt: &ReleaseMigrationPrompt, + plan: Option<&ReleaseMigrationPlan>, + dispatch: crate::Actions, + excluded: State>, + target_running: bool, +) -> impl IntoElement { + let dismiss = dispatch.clone(); + + let chosen: Vec = plan + .map(|plan| { + plan.packages + .iter() + .filter(|package| { + !excluded + .read() + .contains(&selection_key(package, plan.source_cluster_id)) + }) + .cloned() + .collect() + }) + .unwrap_or_default(); + let total = plan.map_or(0, |plan| plan.packages.len()); + let dependency_bytes: u64 = plan.map_or(0, |plan| { + let selected = selected_hashes(plan, &excluded.read()); + plan.dependencies + .iter() + .filter(|dependency| dependency.is_needed_by(&selected)) + .map(ReleaseMigrationDependency::size) + .sum() + }); + let bytes: u64 = chosen.iter().map(|package| package.size).sum::() + dependency_bytes; + let count = chosen.len(); + + let summary = format!( + "{count} of {total} packages selected · {} to copy", + format_size(bytes) + ); + + let enabled = count > 0 && !target_running; + let mut migrate = Button::new() + .primary() + .enabled(enabled) + .width(Size::px(176.)) + .height(Size::px(52.)) + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .on_press(move |_| dispatch.migrate_release_packages(chosen.clone())) + .text("Migrate"); + if target_running { + migrate = migrate.tooltip(format!("Close {} before migrating", prompt.target.name)); + } + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new(16., 24., 16., 24.)) + .background(FOOTER_BG) + .child( + label() + .text(summary) + .font_size(12.) + .width(Size::flex(1.)) + .max_lines(2) + .color(colors::fg_secondary()), + ) + .child( + Button::new() + .ghost() + .height(Size::px(52.)) + .padding(Gaps::new_symmetric(0., 16.)) + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .on_press(move |_| dismiss.dismiss_release_migration()) + .text("Not now"), + ) + .child(migrate) +} + +struct MigrationRow { + name: String, + icon_url: Option, + package: ReleaseMigrationPackage, + selected: bool, + selection: SelectionKey, + excluded: State>, + key: DiffKey, +} + +impl PartialEq for MigrationRow { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + && self.icon_url == other.icon_url + && self.package.source_hash == other.package.source_hash + && self.package.version.version_id == other.package.version.version_id + && self.package.enabled == other.package.enabled + && self.selected == other.selected + && self.selection == other.selection + } +} + +impl KeyExt for MigrationRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for MigrationRow { + fn render(&self) -> impl IntoElement { + let icon_query = use_cached_image(self.icon_url.clone(), 96); + let mut hovered = use_state(|| false); + let mut excluded = self.excluded; + let selection = self.selection.clone(); + let selected = self.selected; + + let icon = match loaded_image(self.icon_url.as_deref(), &icon_query) { + Some((url, bytes)) => rect() + .width(Size::px(ROW_ICON)) + .height(Size::px(ROW_ICON)) + .corner_radius(CornerRadius::new_all(8.)) + .overflow(Overflow::Clip) + .child( + ImageViewer::new((url, bytes)) + .width(Size::px(ROW_ICON)) + .height(Size::px(ROW_ICON)) + .aspect_ratio(AspectRatio::Min) + .fallback(placeholder_icon()), + ) + .into_element(), + None => placeholder_icon().into_element(), + }; + + let provider = provider_label(self.package.provider); + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(56.)) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(14.) + .padding(Gaps::new_symmetric(0., 12.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(if *hovered.read() { + colors::ghost_overlay() + } else { + Color::TRANSPARENT + }) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_press(move |_| { + let mut set = excluded.write(); + if selected { + set.insert(selection.clone()); + } else { + set.remove(&selection); + } + }) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.)) + .spacing(2.) + .child( + label() + .text(self.name.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_primary()), + ) + .child( + label() + .text(if self.package.enabled { + format!("{provider} · {}", self.package.version_name) + } else { + format!("{provider} · {} · Disabled", self.package.version_name) + }) + .font_size(12.) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_secondary()), + ), + ) + .maybe_child((self.package.size > 0).then(|| { + label() + .text(format_size(self.package.size)) + .font_size(12.) + .color(colors::fg_secondary()) + })) + .child(check_box(selected)) + } +} + +fn placeholder_icon() -> impl IntoElement { + rect() + .width(Size::px(ROW_ICON)) + .height(Size::px(ROW_ICON)) + .center() + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1., colors::component_border()).alignment(BorderAlignment::Inner)) + .child( + Icon::new(IconType::DotsGrid) + .size(14.) + .color(colors::fg_secondary()), + ) +} + +fn check_box(checked: bool) -> impl IntoElement { + rect() + .width(Size::px(CHECK_SIZE)) + .height(Size::px(CHECK_SIZE)) + .center() + .corner_radius(CornerRadius::new_all(6.)) + .background(if checked { + colors::brand() + } else { + Color::TRANSPARENT + }) + .border( + border_all_color( + 1., + if checked { + colors::brand() + } else { + colors::component_border() + }, + ) + .alignment(BorderAlignment::Inner), + ) + .maybe_child(checked.then(|| Icon::new(IconType::Check).size(14.).color(Color::WHITE))) +} diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index d0ef100f..b50351ae 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -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() { diff --git a/packages/oneclient_app/src/hooks/actions/release_migration.rs b/packages/oneclient_app/src/hooks/actions/release_migration.rs new file mode 100644 index 00000000..4f6fb86f --- /dev/null +++ b/packages/oneclient_app/src/hooks/actions/release_migration.rs @@ -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) -> 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, + plans: HashMap, + }, + Nothing, + Unreachable, +} + +async fn plan_sources( + offer: &ReleaseMigrationOffer, + bundles: &oneclient_content::bundles::BundlesManager, + content: &oneclient_content::ContentCtx, +) -> SourcePlans { + let ids: Vec = offer.sources.iter().map(|source| source.id).collect(); + let mut checked: HashMap = + 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, +) -> 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)) { + let mut guard = self + .station + .clone() + .write_channel(AppChannel::ReleaseMigration); + edit(&mut guard.release_migration); + } + + fn remove_pending_release_migrations(&self, keys: Vec) { + 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, + offer: ReleaseMigrationOffer, + sources: Vec, + plans: HashMap, + 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) { + 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 = 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 = 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; + }); + } +} diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index e28e2da2..8a692c63 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -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 { + use_radio(AppChannel::ReleaseMigration) + .read() + .release_migration + .clone() +} + pub fn use_relocation() -> RelocationState { use_radio(AppChannel::Relocation).read().relocation.clone() } diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 96739117..b8e7f0db 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -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) diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs index a34292f7..f2b639a3 100644 --- a/packages/oneclient_app/src/state.rs +++ b/packages/oneclient_app/src/state.rs @@ -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 for AppChannel {} @@ -49,6 +52,54 @@ pub struct AppState { pub storage_scan: Option, pub relocation: RelocationState, pub pending_launch: Option, + pub release_migration: Option, + pub release_migration_checking: HashSet, +} + +#[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, + pub sources: Vec, + pub selected: i64, + pub plans: HashMap, + 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 diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index 7d06ceaf..6c38529e 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -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::); + + 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 = sources.iter().map(|source| source.id).collect(); + let names: Vec = 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, diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index 8e0f8eac..7625f2f4 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -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 impl IntoElement { + let dispatch = use_dispatch(); + let clusters_query = use_clusters(); + let mut target = use_state(|| None::); + + 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 = clusters.iter().map(|cluster| cluster.id).collect(); + let options: Vec = 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; diff --git a/packages/oneclient_content/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs index 0273ef9f..ca90b16f 100644 --- a/packages/oneclient_content/src/bundles/install.rs +++ b/packages/oneclient_content/src/bundles/install.rs @@ -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> { + 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, diff --git a/packages/oneclient_content/src/bundles/mod.rs b/packages/oneclient_content/src/bundles/mod.rs index ca5661ee..4977d23b 100644 --- a/packages/oneclient_content/src/bundles/mod.rs +++ b/packages/oneclient_content/src/bundles/mod.rs @@ -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; diff --git a/packages/oneclient_content/src/packages/dependencies.rs b/packages/oneclient_content/src/packages/dependencies.rs index 24ad2716..7b0b6171 100644 --- a/packages/oneclient_content/src/packages/dependencies.rs +++ b/packages/oneclient_content/src/packages/dependencies.rs @@ -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, diff --git a/packages/oneclient_content/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs index 482d6cf2..e738832a 100644 --- a/packages/oneclient_content/src/packages/mod.rs +++ b/packages/oneclient_content/src/packages/mod.rs @@ -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; diff --git a/packages/oneclient_content/src/packages/provider/curseforge.rs b/packages/oneclient_content/src/packages/provider/curseforge.rs index 53f5366b..cb935117 100644 --- a/packages/oneclient_content/src/packages/provider/curseforge.rs +++ b/packages/oneclient_content/src/packages/provider/curseforge.rs @@ -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> { + let mod_ids: Vec = packages + .iter() + .filter_map(|package| package.project_id.parse().ok()) + .collect::>() + .into_iter() + .collect(); + if mod_ids.is_empty() { + return Ok(HashMap::new()); + } + + let response: CfData> = fetch_json( + &ctx.net, + Method::POST, + &api_url("/mods"), + Some(serde_json::json!({ "modIds": mod_ids })), + ) + .await?; + let indexes: HashMap> = 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 = picks + .iter() + .map(|(_, file_id)| file_id.to_string()) + .collect::>() + .into_iter() + .collect(); + let versions: HashMap = 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, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfFileIndex { + game_version: String, + file_id: u32, + release_type: u8, + #[serde(default)] + mod_loader: Option, +} + +fn pick_file_index( + files: &[CfFileIndex], + content_type: ContentType, + mc_version: &str, + loader: GameLoader, +) -> Option { + 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)] diff --git a/packages/oneclient_content/src/packages/provider/mod.rs b/packages/oneclient_content/src/packages/provider/mod.rs index 48367712..06584e4a 100644 --- a/packages/oneclient_content/src/packages/provider/mod.rs +++ b/packages/oneclient_content/src/packages/provider/mod.rs @@ -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; + async fn latest_for_game_version( + &self, + packages: &[InstalledPackage], + mc_version: &str, + loader: oneclient_common::domain::GameLoader, + ctx: &ContentCtx, + ) -> ContentResult>; + async fn list_categories( &self, _content_type: oneclient_common::domain::ContentType, diff --git a/packages/oneclient_content/src/packages/provider/modrinth.rs b/packages/oneclient_content/src/packages/provider/modrinth.rs index a4d028d5..05e27a42 100644 --- a/packages/oneclient_content/src/packages/provider/modrinth.rs +++ b/packages/oneclient_content/src/packages/provider/modrinth.rs @@ -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> { + let mut groups: HashMap> = 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 = 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> { + 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> { + 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 = 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))] diff --git a/packages/oneclient_content/src/packages/release_migration.rs b/packages/oneclient_content/src/packages/release_migration.rs new file mode 100644 index 00000000..65db538d --- /dev/null +++ b/packages/oneclient_content/src/packages/release_migration.rs @@ -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, +} + +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) -> 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, + pub dependencies: Vec, + pub unavailable: Vec, + 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> { + 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 = tracked + .iter() + .filter_map(|row| row.package_id.clone()) + .collect(); + let bundle_hashes: HashSet = 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 { + Ok(!migratable_candidates(cluster_id, ctx).await?.is_empty()) +} + +async fn target_projects( + target_cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult> { + let mut projects: HashSet = 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; +type LookupCells = HashMap<(ProviderId, String), Arc>>; + +struct Unreachable; + +#[derive(Default)] +pub struct DependencyCache { + entries: Mutex, +} + +fn dependency_key(dep: &VersionDependency) -> Option { + 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> { + 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 { + 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, + ctx: &ContentCtx, + ) -> Result>, Unreachable> { + let mut seen: HashSet = 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)> { + 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::>() + .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 { + let target = PackageStore::get_cluster(target_cluster_id, ctx).await?; + let present = target_projects(target_cluster_id, bundles, ctx).await?; + + let candidates: Vec = 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, + dependencies: Vec, + unavailable: Vec, + unreachable: bool, +} + +async fn evaluate_candidates( + candidates: Vec, + target: &ClusterRow, + present: &HashSet, + 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> = HashMap::new(); + for candidate in candidates { + by_provider + .entry(candidate.provider) + .or_default() + .push(candidate); + } + + let mut offered: Vec = Vec::new(); + + for (provider_id, candidates) in by_provider { + let installed: Vec = 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::>() + .await; + + let mut dependencies: Vec = 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 = 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, +} + +#[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> { + 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> = + 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 = 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::>(), + dependencies = ?evaluation + .dependencies + .iter() + .map(|dependency| dependency.project.name.clone()) + .collect::>(), + still_waiting = ?evaluation + .unavailable + .iter() + .map(|skip| format!("{} ({:?})", skip.display_name, skip.reason)) + .collect::>(), + "waitlist check finished for {} builds", + target.mc_version + ); + + let mut names = Vec::new(); + let mut blocked: HashSet = 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 { + 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 { + 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) -> 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") + ); + } +} diff --git a/packages/oneclient_content/src/packages/types.rs b/packages/oneclient_content/src/packages/types.rs index a0d647f7..e68677ab 100644 --- a/packages/oneclient_content/src/packages/types.rs +++ b/packages/oneclient_content/src/packages/types.rs @@ -232,6 +232,13 @@ pub struct ProviderReleaseInfo { } pub type VersionLookup = HashMap; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstalledPackage { + pub hash: String, + pub project_id: String, + pub content_type: ContentType, +} pub type ProviderVersionLookup = HashMap; #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/packages/oneclient_content/src/packages/updates.rs b/packages/oneclient_content/src/packages/updates.rs index e86ec3f3..e7effafc 100644 --- a/packages/oneclient_content/src/packages/updates.rs +++ b/packages/oneclient_content/src/packages/updates.rs @@ -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>, +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>, + 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, + bundle_projects: &HashSet, +) -> Vec { + 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, bundle_projects: &HashSet, ) -> Vec { 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 { parse_published(raw).expect("test dates are rfc 3339") diff --git a/packages/oneclient_core/src/clusters/mod.rs b/packages/oneclient_core/src/clusters/mod.rs index 37be52b9..6c97c017 100644 --- a/packages/oneclient_core/src/clusters/mod.rs +++ b/packages/oneclient_core/src/clusters/mod.rs @@ -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::{ diff --git a/packages/oneclient_core/src/clusters/release_migration.rs b/packages/oneclient_core/src/clusters/release_migration.rs new file mode 100644 index 00000000..93fd3959 --- /dev/null +++ b/packages/oneclient_core/src/clusters/release_migration.rs @@ -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, +} + +#[derive(Debug, Clone)] +pub enum OfferLookup { + MissingCluster, + NoSources, + Offer(Box), +} + +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 = 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 { + 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 { + 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 { + let target_order = version_order(&target.mc_version); + let mut sources: Vec = 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 { + 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")); + } +} diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index e9878b5a..84f3dbbe 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -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, }; diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs index 9439193d..29d7c893 100644 --- a/packages/oneclient_core/src/settings/launcher.rs +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -74,6 +74,7 @@ pub struct LauncherSettings { pub declined_tos: bool, pub seen_versions: Vec, pub seen_changelog_version: Option, + pub pending_release_migrations: Vec, pub modrinth_api_key: Option, pub curseforge_api_key: Option, pub custom_api_endpoint: Option, @@ -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, diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs index 6ad5102d..51068bde 100644 --- a/packages/oneclient_core/src/state.rs +++ b/packages/oneclient_core/src/state.rs @@ -147,6 +147,9 @@ pub fn run_startup_tasks(state: &Arc) { 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:#}"); } diff --git a/packages/oneclient_core/src/versions/manager.rs b/packages/oneclient_core/src/versions/manager.rs index d1ab08be..2bfe2134 100644 --- a/packages/oneclient_core/src/versions/manager.rs +++ b/packages/oneclient_core/src/versions/manager.rs @@ -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, + added: std::sync::Mutex>, } 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(¤t, &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 { + std::mem::take(&mut *self.added.lock().unwrap()) + } + #[tracing::instrument(level = "debug", skip(services))] async fn fetch_manifest( services: &LauncherServices, diff --git a/packages/oneclient_core/src/versions/manifest.rs b/packages/oneclient_core/src/versions/manifest.rs index fc014155..2874d747 100644 --- a/packages/oneclient_core/src/versions/manifest.rs +++ b/packages/oneclient_core/src/versions/manifest.rs @@ -43,6 +43,14 @@ pub struct RemoteEntry { pub long_description: Option, #[serde(default)] pub tags: Option>, + #[serde(default)] + pub flags: EntryFlags, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntryFlags { + #[serde(default)] + pub show_initial_migration: Option, } #[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 { + 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 { + 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 { + if previous.clusters.is_empty() { + return Vec::new(); + } + let known = release_targets(previous); + let mut added: Vec = 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( diff --git a/packages/oneclient_core/src/versions/mod.rs b/packages/oneclient_core/src/versions/mod.rs index e9ace6ba..0e5e45b7 100644 --- a/packages/oneclient_core/src/versions/mod.rs +++ b/packages/oneclient_core/src/versions/mod.rs @@ -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, }; diff --git a/packages/oneclient_db/migrations/20260918120000_release_migration_waitlist.sql b/packages/oneclient_db/migrations/20260918120000_release_migration_waitlist.sql new file mode 100644 index 00000000..e557a874 --- /dev/null +++ b/packages/oneclient_db/migrations/20260918120000_release_migration_waitlist.sql @@ -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); diff --git a/packages/oneclient_db/migrations/20260918130000_release_migration_waitlist_enabled.sql b/packages/oneclient_db/migrations/20260918130000_release_migration_waitlist_enabled.sql new file mode 100644 index 00000000..2e740bf9 --- /dev/null +++ b/packages/oneclient_db/migrations/20260918130000_release_migration_waitlist_enabled.sql @@ -0,0 +1 @@ +ALTER TABLE release_migration_waitlist ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1; diff --git a/packages/oneclient_db/src/dao/mod.rs b/packages/oneclient_db/src/dao/mod.rs index 16fd2281..11d84cd8 100644 --- a/packages/oneclient_db/src/dao/mod.rs +++ b/packages/oneclient_db/src/dao/mod.rs @@ -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; diff --git a/packages/oneclient_db/src/dao/release_migration_waitlist.rs b/packages/oneclient_db/src/dao/release_migration_waitlist.rs new file mode 100644 index 00000000..37313cb7 --- /dev/null +++ b/packages/oneclient_db/src/dao/release_migration_waitlist.rs @@ -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, 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 { + let result = sqlx::query!( + "DELETE FROM release_migration_waitlist WHERE expires_at <= ?", + now + ) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} diff --git a/packages/oneclient_db/src/models/mod.rs b/packages/oneclient_db/src/models/mod.rs index 9c0921f3..cb7dd3ca 100644 --- a/packages/oneclient_db/src/models/mod.rs +++ b/packages/oneclient_db/src/models/mod.rs @@ -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; diff --git a/packages/oneclient_db/src/models/release_migration_waitlist.rs b/packages/oneclient_db/src/models/release_migration_waitlist.rs new file mode 100644 index 00000000..28118c91 --- /dev/null +++ b/packages/oneclient_db/src/models/release_migration_waitlist.rs @@ -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, +}