feat: release migration (#851)
* feat: added migration mechanism for versions 26.3 and above * feat: added checking for mods that the migrating target already has * feat: added scheduled migrations for up to 30 days * feat: added button in cluster settings for manual migratting; added migratting disabled mods with status saving * feat: added checking for show_initial_migration flag in metadata.json * feat: added fabric -> orinthe migration (for 1.8.9) * chore: force open from debug page * chore: change some UI elements * fix: don't migrate packages whose dependency failed to install * fix: keep checking the release migration waitlist past a broken cluster * fix: make Migrate on a debug fake plan a no-op * perf: install the version the migration plan already fetched * chore: fix the clippy warnings the release migration added * refactor: open every release migration prompt through one helper * chore(lint): cargo fmt the release migration code * fix: check the release migration waitlist queries at compile time --------- Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
This commit is contained in:
parent
abb41dcc52
commit
45da20fac2
38 changed files with 4587 additions and 51 deletions
110
.sqlx/query-15dc690e91fe0bc7e1932a5051f8edd855deaeb31e52209ae436680962dea954.json
generated
Normal file
110
.sqlx/query-15dc690e91fe0bc7e1932a5051f8edd855deaeb31e52209ae436680962dea954.json
generated
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tSELECT\n\t\t\ttarget_cluster_id, provider, project_id, content_type,\n\t\t\tsource_hash, display_name, expires_at, enabled\n\t\tFROM release_migration_waitlist\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "target_cluster_id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "release_migration_waitlist",
|
||||
"name": "target_cluster_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "provider",
|
||||
"ordinal": 1,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "release_migration_waitlist",
|
||||
"name": "provider"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "project_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "release_migration_waitlist",
|
||||
"name": "project_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "content_type",
|
||||
"ordinal": 3,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "release_migration_waitlist",
|
||||
"name": "content_type"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "source_hash",
|
||||
"ordinal": 4,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "release_migration_waitlist",
|
||||
"name": "source_hash"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 5,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "release_migration_waitlist",
|
||||
"name": "display_name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "expires_at",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "release_migration_waitlist",
|
||||
"name": "expires_at"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "enabled",
|
||||
"ordinal": 7,
|
||||
"type_info": "Integer",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "release_migration_waitlist",
|
||||
"name": "enabled"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "15dc690e91fe0bc7e1932a5051f8edd855deaeb31e52209ae436680962dea954"
|
||||
}
|
||||
12
.sqlx/query-1e43f93b56bb495e13826c734fe5fb64515cf1d7c28bc6a1bf1fb1645fdba2a1.json
generated
Normal file
12
.sqlx/query-1e43f93b56bb495e13826c734fe5fb64515cf1d7c28bc6a1bf1fb1645fdba2a1.json
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM release_migration_waitlist WHERE target_cluster_id = ? AND provider = ? AND project_id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1e43f93b56bb495e13826c734fe5fb64515cf1d7c28bc6a1bf1fb1645fdba2a1"
|
||||
}
|
||||
12
.sqlx/query-e12409b04c8a3eea09a4bfc1014bca6fe5fbc1256b3537ed37195ed20075f944.json
generated
Normal file
12
.sqlx/query-e12409b04c8a3eea09a4bfc1014bca6fe5fbc1256b3537ed37195ed20075f944.json
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n\t\tINSERT INTO release_migration_waitlist (\n\t\t\ttarget_cluster_id, provider, project_id, content_type,\n\t\t\tsource_hash, display_name, added_at, expires_at, enabled\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT(target_cluster_id, provider, project_id) DO UPDATE SET\n\t\t\tcontent_type = excluded.content_type,\n\t\t\tsource_hash = excluded.source_hash,\n\t\t\tdisplay_name = excluded.display_name,\n\t\t\tadded_at = excluded.added_at,\n\t\t\texpires_at = excluded.expires_at,\n\t\t\tenabled = excluded.enabled\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 9
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e12409b04c8a3eea09a4bfc1014bca6fe5fbc1256b3537ed37195ed20075f944"
|
||||
}
|
||||
12
.sqlx/query-ff07b78c0e97f1f38a6900092391d22e475471578b5ef73927e5427c2c0abc77.json
generated
Normal file
12
.sqlx/query-ff07b78c0e97f1f38a6900092391d22e475471578b5ef73927e5427c2c0abc77.json
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM release_migration_waitlist WHERE expires_at <= ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ff07b78c0e97f1f38a6900092391d22e475471578b5ef73927e5427c2c0abc77"
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ pub struct Dropdown {
|
|||
leading: Option<Element>,
|
||||
width: Size,
|
||||
height: Size,
|
||||
outlined: bool,
|
||||
key: DiffKey,
|
||||
}
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ impl Dropdown {
|
|||
leading: None,
|
||||
width: Size::px(72.),
|
||||
height: Size::px(24.),
|
||||
outlined: false,
|
||||
key: DiffKey::None,
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +47,11 @@ impl Dropdown {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn outlined(mut self) -> Self {
|
||||
self.outlined = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn leading(mut self, leading: impl IntoElement) -> Self {
|
||||
self.leading = Some(leading.into_element());
|
||||
self
|
||||
|
|
@ -77,6 +84,7 @@ impl Component for Dropdown {
|
|||
let leading = self.leading.clone();
|
||||
let options = self.options.clone();
|
||||
let on_select = self.on_select.clone();
|
||||
let outlined = self.outlined;
|
||||
let is_open = open();
|
||||
|
||||
use_overlay_claim_when(is_open);
|
||||
|
|
@ -131,9 +139,13 @@ impl Component for Dropdown {
|
|||
.horizontal()
|
||||
.center()
|
||||
.spacing(4.)
|
||||
.padding(Gaps::new_symmetric(0., 8.))
|
||||
.corner_radius(CornerRadius::new_all(6.))
|
||||
.padding(Gaps::new_symmetric(0., if outlined { 12. } else { 8. }))
|
||||
.corner_radius(CornerRadius::new_all(if outlined { 8. } else { 6. }))
|
||||
.background(trigger_bg)
|
||||
.maybe(outlined, |el| {
|
||||
el.main_align(Alignment::SpaceBetween)
|
||||
.border(border_all_color(1., colors::component_border()))
|
||||
})
|
||||
.a11y_id(a11y_id)
|
||||
.a11y_focusable(true)
|
||||
.a11y_role(AccessibilityRole::Button)
|
||||
|
|
@ -155,7 +167,7 @@ impl Component for Dropdown {
|
|||
.child(
|
||||
label()
|
||||
.text(selected)
|
||||
.font_size(12.)
|
||||
.font_size(if outlined { 13. } else { 12. })
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
1348
packages/oneclient_app/src/components/release_migration_popup.rs
Normal file
1348
packages/oneclient_app/src/components/release_migration_popup.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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() {
|
||||
|
|
|
|||
821
packages/oneclient_app/src/hooks/actions/release_migration.rs
Normal file
821
packages/oneclient_app/src/hooks/actions/release_migration.rs
Normal file
|
|
@ -0,0 +1,821 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use freya::prelude::spawn_forever;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use oneclient_content::packages::release_migration::{
|
||||
ReleaseMigrationDependency, ReleaseMigrationPackage, ReleaseMigrationPlan,
|
||||
ReleaseMigrationSkip, SkipReason, add_to_waitlist, apply_release_migration_dependency,
|
||||
apply_release_migration_package, plan_release_migrations, process_waitlist,
|
||||
};
|
||||
use oneclient_content::packages::{
|
||||
ContentType, GameLoader, PackageBody, ProjectDetail, ProviderId, VersionDetail, VersionFile,
|
||||
};
|
||||
use oneclient_core::ReleaseTarget;
|
||||
use oneclient_core::clusters::{
|
||||
Cluster, OfferLookup, ReleaseMigrationOffer, manual_migration_offer, record_new_versions,
|
||||
release_migration_offer,
|
||||
};
|
||||
use oneclient_events::Level;
|
||||
|
||||
use super::Actions;
|
||||
use crate::components::IconType;
|
||||
use crate::launcher;
|
||||
use crate::notifications::NotificationSpec;
|
||||
use crate::state::{AppChannel, PromptOrigin, ReleaseMigrationPrompt, ReleasePlanState};
|
||||
|
||||
enum SourcePlan {
|
||||
Offer(ReleaseMigrationPlan),
|
||||
Nothing,
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
fn classify(plan: Result<ReleaseMigrationPlan, oneclient_content::ContentError>) -> SourcePlan {
|
||||
match plan {
|
||||
Ok(plan) if plan.unreachable => SourcePlan::Unreachable,
|
||||
Ok(plan) if plan.packages.is_empty() => SourcePlan::Nothing,
|
||||
Ok(plan) => SourcePlan::Offer(plan),
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, "release migration plan failed");
|
||||
SourcePlan::Unreachable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SourcePlans {
|
||||
Ready {
|
||||
sources: Vec<Cluster>,
|
||||
plans: HashMap<i64, ReleasePlanState>,
|
||||
},
|
||||
Nothing,
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
async fn plan_sources(
|
||||
offer: &ReleaseMigrationOffer,
|
||||
bundles: &oneclient_content::bundles::BundlesManager,
|
||||
content: &oneclient_content::ContentCtx,
|
||||
) -> SourcePlans {
|
||||
let ids: Vec<i64> = offer.sources.iter().map(|source| source.id).collect();
|
||||
let mut checked: HashMap<i64, SourcePlan> =
|
||||
plan_release_migrations(&ids, offer.target.id, bundles, content)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|(source_id, plan)| (source_id, classify(plan)))
|
||||
.collect();
|
||||
|
||||
let mut sources = Vec::new();
|
||||
let mut plans = HashMap::new();
|
||||
|
||||
for source in &offer.sources {
|
||||
match checked.remove(&source.id) {
|
||||
Some(SourcePlan::Offer(plan)) => {
|
||||
plans.insert(source.id, ReleasePlanState::Ready(plan));
|
||||
sources.push(source.clone());
|
||||
}
|
||||
Some(SourcePlan::Nothing) | None => {}
|
||||
Some(SourcePlan::Unreachable) => return SourcePlans::Unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
if sources.is_empty() {
|
||||
SourcePlans::Nothing
|
||||
} else {
|
||||
SourcePlans::Ready { sources, plans }
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_version(name: &str, project_id: &str, size: u64) -> VersionDetail {
|
||||
VersionDetail {
|
||||
version_id: format!("fake-version-{project_id}"),
|
||||
project_id: project_id.to_string(),
|
||||
name: name.to_string(),
|
||||
version_number: "1.0.0".to_string(),
|
||||
changelog: None,
|
||||
game_versions: Vec::new(),
|
||||
loaders: vec![GameLoader::Fabric],
|
||||
published: chrono::Utc::now(),
|
||||
downloads: 0,
|
||||
files: vec![VersionFile {
|
||||
sha1: format!("fake-{project_id}"),
|
||||
url: String::new(),
|
||||
file_name: format!("{project_id}.jar"),
|
||||
primary: true,
|
||||
size,
|
||||
fingerprint: None,
|
||||
}],
|
||||
dependencies: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_package(
|
||||
name: &str,
|
||||
project_id: &str,
|
||||
content_type: ContentType,
|
||||
enabled: bool,
|
||||
) -> ReleaseMigrationPackage {
|
||||
ReleaseMigrationPackage {
|
||||
source_hash: format!("fake-{project_id}"),
|
||||
enabled,
|
||||
provider: ProviderId::Modrinth,
|
||||
project_id: project_id.to_string(),
|
||||
content_type,
|
||||
display_name: name.to_string(),
|
||||
version: fake_version(name, project_id, 2_400_000),
|
||||
version_name: "1.0.0".to_string(),
|
||||
size: 2_400_000,
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_skip(name: &str, project_id: &str, reason: SkipReason) -> ReleaseMigrationSkip {
|
||||
ReleaseMigrationSkip {
|
||||
source_hash: format!("fake-{project_id}"),
|
||||
enabled: true,
|
||||
provider: ProviderId::Modrinth,
|
||||
project_id: project_id.to_string(),
|
||||
content_type: ContentType::Mod,
|
||||
display_name: name.to_string(),
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_dependency(
|
||||
name: &str,
|
||||
project_id: &str,
|
||||
required_by: Vec<String>,
|
||||
) -> ReleaseMigrationDependency {
|
||||
let now = chrono::Utc::now();
|
||||
ReleaseMigrationDependency {
|
||||
provider: ProviderId::Modrinth,
|
||||
project: ProjectDetail {
|
||||
id: project_id.to_string(),
|
||||
slug: project_id.to_string(),
|
||||
provider: ProviderId::Modrinth,
|
||||
content_type: ContentType::Mod,
|
||||
name: name.to_string(),
|
||||
summary: "Simulated dependency".to_string(),
|
||||
author: "nobody".to_string(),
|
||||
members: Vec::new(),
|
||||
gallery: Vec::new(),
|
||||
body: PackageBody::Raw(String::new()),
|
||||
license: None,
|
||||
links: Vec::new(),
|
||||
version_ids: Vec::new(),
|
||||
game_versions: Vec::new(),
|
||||
loaders: vec![GameLoader::Fabric],
|
||||
icon_url: None,
|
||||
created: now,
|
||||
updated: now,
|
||||
downloads: 0,
|
||||
},
|
||||
version: fake_version(name, project_id, 900_000),
|
||||
required_by,
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_plan(source_cluster_id: i64, target_cluster_id: i64) -> ReleaseMigrationPlan {
|
||||
let packages = vec![
|
||||
fake_package("Sodium", "AANobbMI", ContentType::Mod, true),
|
||||
fake_package(
|
||||
"Just Enough Items (JEI)",
|
||||
"u6dRKJwZ",
|
||||
ContentType::Mod,
|
||||
true,
|
||||
),
|
||||
fake_package("Mod Menu", "mOgUt4GM", ContentType::Mod, false),
|
||||
fake_package(
|
||||
"Fresh Animations",
|
||||
"50dA9Sha",
|
||||
ContentType::ResourcePack,
|
||||
true,
|
||||
),
|
||||
fake_package(
|
||||
"Complementary Shaders",
|
||||
"R6NEzAwj",
|
||||
ContentType::Shader,
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
ReleaseMigrationPlan {
|
||||
source_cluster_id,
|
||||
target_cluster_id,
|
||||
dependencies: vec![fake_dependency(
|
||||
"Fabric API",
|
||||
"P7dR8mSH",
|
||||
vec!["fake-AANobbMI".to_string(), "fake-u6dRKJwZ".to_string()],
|
||||
)],
|
||||
unavailable: vec![
|
||||
fake_skip("Iris Shaders", "YL57xq9U", SkipReason::NoCompatibleVersion),
|
||||
fake_skip(
|
||||
"Distant Horizons",
|
||||
"uCdwusMi",
|
||||
SkipReason::MissingDependency,
|
||||
),
|
||||
],
|
||||
packages,
|
||||
unreachable: false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Actions {
|
||||
fn write_release_migration(&self, edit: impl FnOnce(&mut Option<ReleaseMigrationPrompt>)) {
|
||||
let mut guard = self
|
||||
.station
|
||||
.clone()
|
||||
.write_channel(AppChannel::ReleaseMigration);
|
||||
edit(&mut guard.release_migration);
|
||||
}
|
||||
|
||||
fn remove_pending_release_migrations(&self, keys: Vec<String>) {
|
||||
if keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.edit_settings(move |settings| {
|
||||
settings
|
||||
.pending_release_migrations
|
||||
.retain(|key| !keys.contains(key));
|
||||
});
|
||||
}
|
||||
|
||||
async fn open_release_prompt(
|
||||
&self,
|
||||
state: &std::sync::Arc<oneclient_core::LauncherState>,
|
||||
offer: ReleaseMigrationOffer,
|
||||
sources: Vec<Cluster>,
|
||||
plans: HashMap<i64, ReleasePlanState>,
|
||||
origin: PromptOrigin,
|
||||
) {
|
||||
let java_major = oneclient_core::required_java_major(state, offer.target.id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let selected = sources[0].id;
|
||||
self.write_release_migration(move |prompt| {
|
||||
*prompt = Some(ReleaseMigrationPrompt {
|
||||
key: offer.release.key(),
|
||||
target: offer.target,
|
||||
java_major,
|
||||
sources,
|
||||
selected,
|
||||
plans,
|
||||
origin,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn check_release_migration(&self) {
|
||||
let actions = self.clone();
|
||||
spawn_forever(async move {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
|
||||
if let Err(err) = record_new_versions(&state).await {
|
||||
tracing::warn!(error = %err, "could not record new versions for migration");
|
||||
}
|
||||
actions.refresh_settings_from_core();
|
||||
|
||||
let (pending, onboarded) = {
|
||||
let settings = state.settings.read();
|
||||
(
|
||||
settings.pending_release_migrations.clone(),
|
||||
settings.seen_onboarding,
|
||||
)
|
||||
};
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !onboarded {
|
||||
actions.remove_pending_release_migrations(pending);
|
||||
return;
|
||||
}
|
||||
|
||||
let content = state.services.content();
|
||||
let mut finished = Vec::new();
|
||||
|
||||
for key in pending {
|
||||
let Some(release) = ReleaseTarget::from_key(&key) else {
|
||||
finished.push(key);
|
||||
continue;
|
||||
};
|
||||
|
||||
if !state.versions.shows_initial_migration(&release).await {
|
||||
tracing::info!(
|
||||
key,
|
||||
"pending version opts out of the initial migration offer, dropping it"
|
||||
);
|
||||
finished.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
let offer = match release_migration_offer(&state, &release).await {
|
||||
Ok(OfferLookup::Offer(offer)) => offer,
|
||||
Ok(OfferLookup::NoSources) => {
|
||||
finished.push(key);
|
||||
continue;
|
||||
}
|
||||
Ok(OfferLookup::MissingCluster) => continue,
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, key, "release migration lookup failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let (sources, plans) =
|
||||
match plan_sources(&offer, state.bundles.as_ref(), &content).await {
|
||||
SourcePlans::Ready { sources, plans } => (sources, plans),
|
||||
SourcePlans::Nothing => {
|
||||
finished.push(key);
|
||||
continue;
|
||||
}
|
||||
SourcePlans::Unreachable => continue,
|
||||
};
|
||||
|
||||
actions.remove_pending_release_migrations(finished);
|
||||
actions
|
||||
.open_release_prompt(&state, *offer, sources, plans, PromptOrigin::NewRelease)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
actions.remove_pending_release_migrations(finished);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn select_release_migration_source(&self, source_id: i64) {
|
||||
let mut target_id = None;
|
||||
self.write_release_migration(|prompt| {
|
||||
let Some(prompt) = prompt.as_mut() else {
|
||||
return;
|
||||
};
|
||||
prompt.selected = source_id;
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) = prompt.plans.entry(source_id)
|
||||
{
|
||||
entry.insert(ReleasePlanState::Loading);
|
||||
target_id = Some(prompt.target.id);
|
||||
}
|
||||
});
|
||||
|
||||
let Some(target_id) = target_id else { return };
|
||||
let actions = self.clone();
|
||||
spawn_forever(async move {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let result = plan_release_migrations(
|
||||
&[source_id],
|
||||
target_id,
|
||||
state.bundles.as_ref(),
|
||||
&state.services.content(),
|
||||
)
|
||||
.await
|
||||
.pop()
|
||||
.map(|(_, plan)| plan);
|
||||
let next = match result {
|
||||
Some(Ok(plan)) if !plan.unreachable => ReleasePlanState::Ready(plan),
|
||||
Some(Ok(_)) | None => ReleasePlanState::Failed,
|
||||
Some(Err(err)) => {
|
||||
tracing::warn!(error = %err, source_id, "release migration plan failed");
|
||||
ReleasePlanState::Failed
|
||||
}
|
||||
};
|
||||
actions.write_release_migration(move |prompt| {
|
||||
if let Some(prompt) = prompt.as_mut() {
|
||||
prompt.plans.insert(source_id, next);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Debug only: opens the popup on a hand-built plan, so the UI can be
|
||||
/// reviewed without a cluster that has migratable packages
|
||||
pub fn simulate_fake_release_migration(&self, target_cluster_id: i64) {
|
||||
let actions = self.clone();
|
||||
spawn_forever(async move {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let Ok(clusters) = state.clusters.list().await else {
|
||||
return;
|
||||
};
|
||||
let Some(target) = clusters
|
||||
.iter()
|
||||
.find(|cluster| cluster.id == target_cluster_id)
|
||||
.cloned()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let source = clusters
|
||||
.into_iter()
|
||||
.find(|cluster| cluster.id != target.id)
|
||||
.unwrap_or_else(|| target.clone());
|
||||
let selected = source.id;
|
||||
let release = ReleaseTarget {
|
||||
mc_version: target.mc_version.clone(),
|
||||
loader: target.mc_loader,
|
||||
};
|
||||
let plan = fake_plan(selected, target.id);
|
||||
|
||||
actions.write_release_migration(move |prompt| {
|
||||
*prompt = Some(ReleaseMigrationPrompt {
|
||||
key: release.key(),
|
||||
target,
|
||||
java_major: None,
|
||||
sources: vec![source],
|
||||
selected,
|
||||
plans: HashMap::from([(selected, ReleasePlanState::Ready(plan))]),
|
||||
origin: PromptOrigin::Fake,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn simulate_release_migration(&self, target_cluster_id: i64) {
|
||||
let actions = self.clone();
|
||||
spawn_forever(async move {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
|
||||
let target = match state.clusters.get(target_cluster_id).await {
|
||||
Ok(target) => target,
|
||||
Err(err) => {
|
||||
actions
|
||||
.notify("Release migration simulation failed")
|
||||
.body(err.to_string())
|
||||
.level(Level::Error)
|
||||
.send();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let release = ReleaseTarget {
|
||||
mc_version: target.mc_version.clone(),
|
||||
loader: target.mc_loader,
|
||||
};
|
||||
|
||||
let offer = match release_migration_offer(&state, &release).await {
|
||||
Ok(OfferLookup::Offer(offer)) => offer,
|
||||
Ok(OfferLookup::NoSources | OfferLookup::MissingCluster) => {
|
||||
actions
|
||||
.notify("Nothing to migrate")
|
||||
.body("All found packages were migrated")
|
||||
.level(Level::Error)
|
||||
.send();
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
actions
|
||||
.notify("Release migration simulation failed")
|
||||
.body(err.to_string())
|
||||
.level(Level::Error)
|
||||
.send();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (sources, plans) =
|
||||
match plan_sources(&offer, state.bundles.as_ref(), &state.services.content()).await
|
||||
{
|
||||
SourcePlans::Ready { sources, plans } => (sources, plans),
|
||||
SourcePlans::Nothing => {
|
||||
actions
|
||||
.notify("Nothing to migrate")
|
||||
.body("All found packages were migrated")
|
||||
.level(Level::Error)
|
||||
.send();
|
||||
return;
|
||||
}
|
||||
SourcePlans::Unreachable => {
|
||||
actions
|
||||
.notify("Release migration simulation failed")
|
||||
.body("Couldn't reach Modrinth or CurseForge.")
|
||||
.level(Level::Error)
|
||||
.send();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
actions
|
||||
.open_release_prompt(&state, *offer, sources, plans, PromptOrigin::Simulated)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
fn set_release_migration_checking(&self, cluster_id: i64, checking: bool) {
|
||||
let mut guard = self
|
||||
.station
|
||||
.clone()
|
||||
.write_channel(AppChannel::ReleaseMigration);
|
||||
if checking {
|
||||
guard.release_migration_checking.insert(cluster_id);
|
||||
} else {
|
||||
guard.release_migration_checking.remove(&cluster_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_manual_migration(&self, target_cluster_id: i64, source_cluster_id: i64) {
|
||||
self.set_release_migration_checking(target_cluster_id, true);
|
||||
let actions = self.clone();
|
||||
spawn_forever(async move {
|
||||
actions
|
||||
.run_manual_migration(target_cluster_id, source_cluster_id)
|
||||
.await;
|
||||
actions.set_release_migration_checking(target_cluster_id, false);
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_manual_migration(&self, target_cluster_id: i64, source_cluster_id: i64) {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
|
||||
let nothing = || {
|
||||
self.notify("Nothing to migrate")
|
||||
.body("No packages to migrate")
|
||||
.send();
|
||||
};
|
||||
|
||||
let offer = match manual_migration_offer(&state, target_cluster_id, source_cluster_id).await
|
||||
{
|
||||
Ok(OfferLookup::Offer(offer)) => offer,
|
||||
Ok(OfferLookup::NoSources) => {
|
||||
nothing();
|
||||
return;
|
||||
}
|
||||
Ok(OfferLookup::MissingCluster) => return,
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, target_cluster_id, "manual migration lookup failed");
|
||||
self.notify("Couldn't check for packages to migrate")
|
||||
.body(err.to_string())
|
||||
.level(Level::Error)
|
||||
.send();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (sources, plans) = match plan_sources(
|
||||
&offer,
|
||||
state.bundles.as_ref(),
|
||||
&state.services.content(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
SourcePlans::Ready { sources, plans } => (sources, plans),
|
||||
SourcePlans::Nothing => {
|
||||
nothing();
|
||||
return;
|
||||
}
|
||||
SourcePlans::Unreachable => {
|
||||
self.notify("Couldn't check for packages to migrate")
|
||||
.body("Couldn't reach Modrinth or CurseForge. Check your connection and try again.")
|
||||
.level(Level::Error)
|
||||
.send();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.open_release_prompt(&state, *offer, sources, plans, PromptOrigin::Manual)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub fn queue_release_migration(&self, target_cluster_id: i64) {
|
||||
let actions = self.clone();
|
||||
spawn_forever(async move {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let Ok(target) = state.clusters.get(target_cluster_id).await else {
|
||||
return;
|
||||
};
|
||||
let key = ReleaseTarget {
|
||||
mc_version: target.mc_version,
|
||||
loader: target.mc_loader,
|
||||
}
|
||||
.key();
|
||||
|
||||
actions.write_release_migration(|prompt| *prompt = None);
|
||||
actions.edit_settings(move |settings| {
|
||||
if !settings.pending_release_migrations.contains(&key) {
|
||||
settings.pending_release_migrations.push(key);
|
||||
}
|
||||
});
|
||||
actions.check_release_migration();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn process_release_waitlist(&self) {
|
||||
let actions = self.clone();
|
||||
spawn_forever(async move {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let content = state.services.content();
|
||||
|
||||
let installs = match process_waitlist(
|
||||
state.bundles.as_ref(),
|
||||
|cluster_id| oneclient_core::game::is_running(&state, cluster_id),
|
||||
&content,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(installs) => installs,
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, "release migration waitlist check failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if installs.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for install in &installs {
|
||||
let verb = if install.names.len() == 1 {
|
||||
"supports"
|
||||
} else {
|
||||
"support"
|
||||
};
|
||||
actions
|
||||
.notify(format!("New {} builds added", install.mc_version))
|
||||
.body(format!(
|
||||
"{} now {verb} {} and {} added to {}.",
|
||||
install.names.join(", "),
|
||||
install.mc_version,
|
||||
if install.names.len() == 1 {
|
||||
"was"
|
||||
} else {
|
||||
"were"
|
||||
},
|
||||
install.cluster_name
|
||||
))
|
||||
.send();
|
||||
}
|
||||
|
||||
super::super::invalidate_cluster_queries().await;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn dismiss_release_migration(&self) {
|
||||
let mut key = None;
|
||||
self.write_release_migration(|prompt| {
|
||||
key = prompt
|
||||
.take()
|
||||
.filter(|prompt| prompt.origin == PromptOrigin::NewRelease)
|
||||
.map(|prompt| prompt.key);
|
||||
});
|
||||
if let Some(key) = key {
|
||||
self.remove_pending_release_migrations(vec![key]);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn migrate_release_packages(&self, packages: Vec<ReleaseMigrationPackage>) {
|
||||
let mut taken = None;
|
||||
self.write_release_migration(|prompt| taken = prompt.take());
|
||||
let Some(prompt) = taken else { return };
|
||||
match prompt.origin {
|
||||
PromptOrigin::Fake => {
|
||||
self.notify("Fake plan")
|
||||
.body("Nothing was installed")
|
||||
.send();
|
||||
return;
|
||||
}
|
||||
PromptOrigin::Simulated => {}
|
||||
PromptOrigin::NewRelease | PromptOrigin::Manual => {
|
||||
self.remove_pending_release_migrations(vec![prompt.key.clone()]);
|
||||
}
|
||||
}
|
||||
|
||||
if packages.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let chosen: HashSet<String> = packages
|
||||
.iter()
|
||||
.map(|package| package.source_hash.clone())
|
||||
.collect();
|
||||
let (dependencies, unavailable): (Vec<_>, Vec<_>) = match prompt.plans.get(&prompt.selected)
|
||||
{
|
||||
Some(ReleasePlanState::Ready(plan)) => (
|
||||
plan.dependencies
|
||||
.iter()
|
||||
.filter(|dependency| dependency.is_needed_by(&chosen))
|
||||
.map(|dependency| (dependency.clone(), dependency.enabled_for(&packages)))
|
||||
.collect(),
|
||||
plan.unavailable
|
||||
.iter()
|
||||
.filter(|skip| skip.reason == SkipReason::NoCompatibleVersion)
|
||||
.cloned()
|
||||
.collect(),
|
||||
),
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let waitlisted = !prompt.is_cross_loader();
|
||||
|
||||
let actions = self.clone();
|
||||
spawn_forever(async move {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let content = state.services.content();
|
||||
let target = prompt.target;
|
||||
let total = packages.len() + dependencies.len();
|
||||
|
||||
let session = oneclient_events::GroupedProgressSession::start(
|
||||
&state.services.events,
|
||||
format!("Migrating to {}", target.name),
|
||||
);
|
||||
session.expect(
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
total as u64,
|
||||
total as u64,
|
||||
);
|
||||
|
||||
let mut migrated = 0usize;
|
||||
let mut blocked: HashSet<String> = HashSet::new();
|
||||
for (dependency, enabled) in &dependencies {
|
||||
let child = session.child(
|
||||
dependency.project.name.clone(),
|
||||
1,
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
);
|
||||
match apply_release_migration_dependency(
|
||||
target.id,
|
||||
dependency,
|
||||
*enabled,
|
||||
Some(&child),
|
||||
&content,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => migrated += 1,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
dependency = %dependency.project.name,
|
||||
error = %err,
|
||||
"release migration failed for dependency"
|
||||
);
|
||||
blocked.extend(dependency.required_by.iter().cloned());
|
||||
}
|
||||
}
|
||||
child.finish();
|
||||
}
|
||||
|
||||
for package in &packages {
|
||||
let child = session.child(
|
||||
package.display_name.clone(),
|
||||
1,
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
);
|
||||
if blocked.contains(&package.source_hash) {
|
||||
tracing::warn!(
|
||||
package = %package.display_name,
|
||||
"skipping release migration for a package whose dependency failed"
|
||||
);
|
||||
child.finish();
|
||||
continue;
|
||||
}
|
||||
match apply_release_migration_package(target.id, package, Some(&child), &content)
|
||||
.await
|
||||
{
|
||||
Ok(_) => migrated += 1,
|
||||
Err(err) => tracing::warn!(
|
||||
package = %package.display_name,
|
||||
error = %err,
|
||||
"release migration failed for package"
|
||||
),
|
||||
}
|
||||
child.finish();
|
||||
}
|
||||
|
||||
if !waitlisted {
|
||||
tracing::info!(
|
||||
target_cluster_id = target.id,
|
||||
skipped = unavailable.len(),
|
||||
"cross-loader migration, not adding packages without a build to the waitlist"
|
||||
);
|
||||
} else if let Err(err) = add_to_waitlist(target.id, &unavailable, &content).await {
|
||||
tracing::warn!(error = %err, "could not add packages without a build to the migration waitlist");
|
||||
}
|
||||
|
||||
let failed = total - migrated;
|
||||
let session_id = session.detach();
|
||||
let spec = Some(NotificationSpec {
|
||||
title: format!("Migrated to {}", target.name),
|
||||
body: if failed == 0 {
|
||||
format!(
|
||||
"{migrated} package{} copied into {}",
|
||||
if migrated == 1 { "" } else { "s" },
|
||||
target.name
|
||||
)
|
||||
} else {
|
||||
format!("{migrated} of {total} packages copied, {failed} failed")
|
||||
},
|
||||
level: if failed == 0 {
|
||||
Level::Info
|
||||
} else {
|
||||
Level::Error
|
||||
},
|
||||
icon: Some(IconType::DownloadCloud02),
|
||||
progress: None,
|
||||
actions: Vec::new(),
|
||||
toast_only: false,
|
||||
});
|
||||
|
||||
actions.with_engine(|app| {
|
||||
app.notifications
|
||||
.finish_grouped_as_actions(&mut app.inbox, session_id, spec);
|
||||
});
|
||||
|
||||
super::super::invalidate_cluster_queries().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -71,8 +71,8 @@ pub use queries::{
|
|||
|
||||
use crate::notifications::NotificationSnapshot;
|
||||
use crate::state::{
|
||||
AppChannel, GameState, InstallState, LauncherInit, LoginProgress, RelocationState,
|
||||
SettingsState, StorageScanProgress,
|
||||
AppChannel, GameState, InstallState, LauncherInit, LoginProgress, ReleaseMigrationPrompt,
|
||||
RelocationState, SettingsState, StorageScanProgress,
|
||||
};
|
||||
use freya::prelude::*;
|
||||
use freya::radio::use_radio;
|
||||
|
|
@ -92,6 +92,20 @@ pub fn use_launcher() -> LauncherInit {
|
|||
use_radio(AppChannel::Launcher).read().launcher.clone()
|
||||
}
|
||||
|
||||
pub fn use_release_migration_checking(cluster_id: i64) -> bool {
|
||||
use_radio(AppChannel::ReleaseMigration)
|
||||
.read()
|
||||
.release_migration_checking
|
||||
.contains(&cluster_id)
|
||||
}
|
||||
|
||||
pub fn use_release_migration() -> Option<ReleaseMigrationPrompt> {
|
||||
use_radio(AppChannel::ReleaseMigration)
|
||||
.read()
|
||||
.release_migration
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn use_relocation() -> RelocationState {
|
||||
use_radio(AppChannel::Relocation).read().relocation.clone()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ use std::sync::Arc;
|
|||
|
||||
use freya::radio::RadioChannel;
|
||||
use oneclient_common::domain::ProviderId;
|
||||
use oneclient_content::packages::release_migration::ReleaseMigrationPlan;
|
||||
use oneclient_core::clusters::Cluster;
|
||||
use oneclient_core::relocate::{RelocationOutcome, RelocationPlan};
|
||||
use oneclient_core::settings::LauncherSettings;
|
||||
use oneclient_events::LaunchStage;
|
||||
|
|
@ -26,6 +28,7 @@ pub enum AppChannel {
|
|||
StorageScan,
|
||||
Relocation,
|
||||
PendingLaunch,
|
||||
ReleaseMigration,
|
||||
}
|
||||
|
||||
impl RadioChannel<AppState> for AppChannel {}
|
||||
|
|
@ -49,6 +52,54 @@ pub struct AppState {
|
|||
pub storage_scan: Option<StorageScanProgress>,
|
||||
pub relocation: RelocationState,
|
||||
pub pending_launch: Option<String>,
|
||||
pub release_migration: Option<ReleaseMigrationPrompt>,
|
||||
pub release_migration_checking: HashSet<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PromptOrigin {
|
||||
NewRelease,
|
||||
Manual,
|
||||
Simulated,
|
||||
Fake,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ReleasePlanState {
|
||||
Loading,
|
||||
Ready(ReleaseMigrationPlan),
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ReleaseMigrationPrompt {
|
||||
pub key: String,
|
||||
pub target: Cluster,
|
||||
pub java_major: Option<u32>,
|
||||
pub sources: Vec<Cluster>,
|
||||
pub selected: i64,
|
||||
pub plans: HashMap<i64, ReleasePlanState>,
|
||||
pub origin: PromptOrigin,
|
||||
}
|
||||
|
||||
impl ReleaseMigrationPrompt {
|
||||
#[must_use]
|
||||
pub fn source(&self) -> Option<&Cluster> {
|
||||
self.sources
|
||||
.iter()
|
||||
.find(|cluster| cluster.id == self.selected)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn plan(&self) -> Option<&ReleasePlanState> {
|
||||
self.plans.get(&self.selected)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_cross_loader(&self) -> bool {
|
||||
self.source()
|
||||
.is_some_and(|source| source.mc_loader != self.target.mc_loader)
|
||||
}
|
||||
}
|
||||
|
||||
/// A move of the data folder owns the whole window while it runs: the router
|
||||
|
|
|
|||
|
|
@ -10,13 +10,15 @@ use crate::components::{
|
|||
};
|
||||
use crate::hooks::{
|
||||
ClusterAction, java_runtimes, loader_versions, mutation_is_running, query_error,
|
||||
try_game_profile, use_cluster_mutation, use_dispatch, use_game_profile, use_java_runtimes,
|
||||
use_loader_versions, use_settings_snapshot,
|
||||
settled_or_loading, try_game_profile, use_cluster_mutation, use_clusters, use_dispatch,
|
||||
use_game_profile, use_game_snapshot, use_java_runtimes, use_loader_versions,
|
||||
use_release_migration_checking, use_settings_snapshot,
|
||||
};
|
||||
use crate::layout::cluster_content;
|
||||
use crate::theme::colors;
|
||||
use crate::ui::centered_note;
|
||||
use crate::view::app::settings::{section_header, settings_row};
|
||||
use oneclient_core::clusters::rank_migration_sources;
|
||||
|
||||
use super::cluster_not_found;
|
||||
use crate::hooks::use_cluster;
|
||||
|
|
@ -140,6 +142,7 @@ impl Component for ClusterSettings {
|
|||
}
|
||||
.into_element(),
|
||||
)
|
||||
.child(MigrateFromRow { cluster_id }.into_element())
|
||||
.child(section_header("REPAIR"))
|
||||
.child(VerifyFilesRow { cluster_id }.into_element()),
|
||||
)
|
||||
|
|
@ -759,6 +762,91 @@ impl Component for BrowserUpdateModeRow {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct MigrateFromRow {
|
||||
cluster_id: i64,
|
||||
}
|
||||
|
||||
impl Component for MigrateFromRow {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let cluster_id = self.cluster_id;
|
||||
let dispatch = use_dispatch();
|
||||
let checking = use_release_migration_checking(cluster_id);
|
||||
let running = use_game_snapshot().is_running(cluster_id);
|
||||
let clusters = settled_or_loading(&use_clusters()).unwrap_or_default();
|
||||
let mut picked = use_state(|| None::<i64>);
|
||||
|
||||
let sources = clusters
|
||||
.iter()
|
||||
.find(|cluster| cluster.id == cluster_id)
|
||||
.map(|target| rank_migration_sources(target, &clusters))
|
||||
.unwrap_or_default();
|
||||
|
||||
let selected = picked
|
||||
.read()
|
||||
.and_then(|id| sources.iter().find(|source| source.id == id))
|
||||
.or_else(|| sources.first())
|
||||
.cloned();
|
||||
let selected_id = selected.as_ref().map(|source| source.id);
|
||||
|
||||
let mut button = Button::new()
|
||||
.secondary()
|
||||
.enabled(selected_id.is_some() && !checking && !running)
|
||||
.on_press(move |_| {
|
||||
if let Some(source_id) = selected_id {
|
||||
dispatch.open_manual_migration(cluster_id, source_id);
|
||||
}
|
||||
});
|
||||
button = if checking {
|
||||
button
|
||||
.child(Icon::new(IconType::Loading02).size(14.))
|
||||
.text("Checking…")
|
||||
} else {
|
||||
button
|
||||
.child(Icon::new(IconType::Copy01).size(14.))
|
||||
.text("Migrate...")
|
||||
};
|
||||
if running {
|
||||
button = button.tooltip("Close the game before migrating");
|
||||
}
|
||||
|
||||
let picker: Element = if sources.is_empty() {
|
||||
label()
|
||||
.text("No other clusters with this loader")
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary())
|
||||
.into_element()
|
||||
} else {
|
||||
let ids: Vec<i64> = sources.iter().map(|source| source.id).collect();
|
||||
let names: Vec<String> = sources.iter().map(|source| source.name.clone()).collect();
|
||||
Dropdown::new(
|
||||
selected.map(|source| source.name).unwrap_or_default(),
|
||||
names,
|
||||
)
|
||||
.width(Size::px(180.))
|
||||
.height(Size::px(34.))
|
||||
.on_select(move |index: usize| {
|
||||
if let Some(id) = ids.get(index) {
|
||||
picked.set(Some(*id));
|
||||
}
|
||||
})
|
||||
.into_element()
|
||||
};
|
||||
|
||||
settings_row(
|
||||
IconType::Copy01,
|
||||
"Migrate",
|
||||
"Bring mods, resource packs and shaders you installed in another cluster. Compatible versions are downloaded; the other cluster is left as it is.",
|
||||
rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.child(picker)
|
||||
.child(button),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct LoaderRow {
|
||||
cluster_id: i64,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,11 @@ impl Component for Debug {
|
|||
vec![ClusterUpdateSimulator.into_element()],
|
||||
))
|
||||
.child(divider())
|
||||
.child(section(
|
||||
"Release Migration",
|
||||
vec![ReleaseMigrationSimulator.into_element()],
|
||||
))
|
||||
.child(divider())
|
||||
.child(section(
|
||||
"Launcher Auto Update",
|
||||
vec![LauncherUpdateSimulator.into_element()],
|
||||
|
|
@ -580,6 +585,124 @@ fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec<ClusterUpdateSu
|
|||
dispatch.open_optional_mods(optional, None);
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct ReleaseMigrationSimulator;
|
||||
|
||||
fn newest_first(clusters: &mut [oneclient_core::clusters::Cluster]) {
|
||||
clusters.sort_by_key(|cluster| {
|
||||
std::cmp::Reverse(
|
||||
oneclient_common::version::parse_mc_version(&cluster.mc_version).map(|parsed| {
|
||||
(
|
||||
parsed.major,
|
||||
parsed.minor.unwrap_or(0),
|
||||
parsed.patch.unwrap_or(0),
|
||||
)
|
||||
}),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
impl Component for ReleaseMigrationSimulator {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let dispatch = use_dispatch();
|
||||
let clusters_query = use_clusters();
|
||||
let mut target = use_state(|| None::<i64>);
|
||||
|
||||
let mut clusters = settled_or_loading(&clusters_query).unwrap_or_default();
|
||||
newest_first(&mut clusters);
|
||||
|
||||
let selected = target
|
||||
.read()
|
||||
.and_then(|id| clusters.iter().find(|cluster| cluster.id == id))
|
||||
.or_else(|| clusters.first())
|
||||
.cloned();
|
||||
|
||||
let ids: Vec<i64> = clusters.iter().map(|cluster| cluster.id).collect();
|
||||
let options: Vec<String> = clusters
|
||||
.iter()
|
||||
.map(|cluster| cluster.name.clone())
|
||||
.collect();
|
||||
let selected_name = selected
|
||||
.as_ref()
|
||||
.map(|cluster| cluster.name.clone())
|
||||
.unwrap_or_else(|| "No clusters".to_string());
|
||||
let selected_id = selected.as_ref().map(|cluster| cluster.id);
|
||||
|
||||
let simulate = dispatch.clone();
|
||||
let fake = dispatch.clone();
|
||||
let queue = dispatch.clone();
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(10.)
|
||||
.child(
|
||||
label()
|
||||
.text("\"Simulate\" opens the real migration modal with the chosen cluster as the new release and the real compatibility check against every other cluster with the same loader; closing it changes nothing. \"Fake Plan\" skips the check and fills the same modal with made-up packages, so the UI can be reviewed when no cluster has anything to migrate. \"Queue as New Version\" adds the chosen version to the pending list exactly as a new manifest entry would, then runs the startup check, so the real one-time flow can be tested.")
|
||||
.font_size(13.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(12.)
|
||||
.child(
|
||||
label()
|
||||
.text("Target")
|
||||
.font_size(13.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
Dropdown::new(selected_name, options)
|
||||
.width(Size::px(220.))
|
||||
.height(Size::px(32.))
|
||||
.on_select(move |index: usize| {
|
||||
if let Some(id) = ids.get(index) {
|
||||
target.set(Some(*id));
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.primary()
|
||||
.enabled(selected_id.is_some())
|
||||
.child(Icon::new(IconType::RefreshCw01).size(16.))
|
||||
.text("Simulate Release Migration")
|
||||
.on_press(move |_| {
|
||||
if let Some(id) = selected_id {
|
||||
simulate.simulate_release_migration(id);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.secondary()
|
||||
.enabled(selected_id.is_some())
|
||||
.text("Fake Plan")
|
||||
.on_press(move |_| {
|
||||
if let Some(id) = selected_id {
|
||||
fake.simulate_fake_release_migration(id);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.secondary()
|
||||
.enabled(selected_id.is_some())
|
||||
.text("Queue as New Version")
|
||||
.on_press(move |_| {
|
||||
if let Some(id) = selected_id {
|
||||
queue.queue_release_migration(id);
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct LauncherUpdateSimulator;
|
||||
|
||||
|
|
|
|||
|
|
@ -645,6 +645,40 @@ pub async fn list_cluster_bundle_overrides(
|
|||
.collect())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(bundles, ctx))]
|
||||
pub async fn enabled_bundle_projects(
|
||||
cluster_id: i64,
|
||||
bundles: &BundlesManager,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<std::collections::HashSet<String>> {
|
||||
let cluster = PackageStore::get_cluster(cluster_id, ctx).await?;
|
||||
let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| {
|
||||
ContentError::InvalidData {
|
||||
reason: format!("unknown loader {}", cluster.mc_loader),
|
||||
}
|
||||
})?;
|
||||
|
||||
let archives = bundles
|
||||
.archives_for(ctx, &cluster.mc_version, loader)
|
||||
.await?;
|
||||
let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?;
|
||||
|
||||
let mut projects = std::collections::HashSet::new();
|
||||
for archive in &archives {
|
||||
let bundle_name = &archive.manifest.name;
|
||||
for file in &archive.manifest.files {
|
||||
let BundleFileKind::Managed { project_id, .. } = &file.kind else {
|
||||
continue;
|
||||
};
|
||||
if effective_enabled(file, find_override(&overrides, bundle_name, project_id)) {
|
||||
projects.insert(project_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(bundles, progress, ctx))]
|
||||
pub async fn install_cluster_bundles(
|
||||
cluster_id: i64,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@ use super::PackageProvider;
|
|||
use super::http::fetch_json;
|
||||
use crate::ctx::ContentCtx;
|
||||
use crate::error::ContentResult;
|
||||
use crate::packages::dependencies::base_game_version;
|
||||
use crate::packages::file_identity::FileIdentity;
|
||||
use crate::packages::types::{
|
||||
DependencyKind, GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary,
|
||||
ReleaseType, SearchFilters, VersionDependency, VersionDetail, VersionFile, VersionLookup,
|
||||
VersionSummary,
|
||||
DependencyKind, GalleryImage, InstalledPackage, PackageBody, Page, ProjectDetail,
|
||||
ProjectMember, ProjectSummary, ReleaseType, SearchFilters, VersionDependency, VersionDetail,
|
||||
VersionFile, VersionLookup, VersionSummary,
|
||||
};
|
||||
use oneclient_common::constants::{CURSEFORGE_API_URL, CURSEFORGE_GAME_ID};
|
||||
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
|
||||
|
|
@ -276,6 +277,123 @@ impl PackageProvider for CurseForgeProvider {
|
|||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(self, packages, ctx))]
|
||||
async fn latest_for_game_version(
|
||||
&self,
|
||||
packages: &[InstalledPackage],
|
||||
mc_version: &str,
|
||||
loader: GameLoader,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<HashMap<String, VersionDetail>> {
|
||||
let mod_ids: Vec<u32> = packages
|
||||
.iter()
|
||||
.filter_map(|package| package.project_id.parse().ok())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
if mod_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let response: CfData<Vec<CfModFiles>> = fetch_json(
|
||||
&ctx.net,
|
||||
Method::POST,
|
||||
&api_url("/mods"),
|
||||
Some(serde_json::json!({ "modIds": mod_ids })),
|
||||
)
|
||||
.await?;
|
||||
let indexes: HashMap<String, Vec<CfFileIndex>> = response
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|entry| (entry.id.to_string(), entry.latest_files_indexes))
|
||||
.collect();
|
||||
|
||||
let mut picks: Vec<(String, u32)> = Vec::new();
|
||||
for package in packages {
|
||||
let Some(files) = indexes.get(&package.project_id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(file_id) = pick_file_index(files, package.content_type, mc_version, loader)
|
||||
{
|
||||
picks.push((package.hash.clone(), file_id));
|
||||
}
|
||||
}
|
||||
if picks.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let file_ids: Vec<String> = picks
|
||||
.iter()
|
||||
.map(|(_, file_id)| file_id.to_string())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let versions: HashMap<String, VersionDetail> = self
|
||||
.get_versions(&file_ids, ctx)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|version| (version.version_id.clone(), version))
|
||||
.collect();
|
||||
|
||||
Ok(picks
|
||||
.into_iter()
|
||||
.filter_map(|(hash, file_id)| {
|
||||
versions
|
||||
.get(&file_id.to_string())
|
||||
.map(|version| (hash, version.clone()))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CfModFiles {
|
||||
id: u32,
|
||||
#[serde(default)]
|
||||
latest_files_indexes: Vec<CfFileIndex>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CfFileIndex {
|
||||
game_version: String,
|
||||
file_id: u32,
|
||||
release_type: u8,
|
||||
#[serde(default)]
|
||||
mod_loader: Option<u8>,
|
||||
}
|
||||
|
||||
fn pick_file_index(
|
||||
files: &[CfFileIndex],
|
||||
content_type: ContentType,
|
||||
mc_version: &str,
|
||||
loader: GameLoader,
|
||||
) -> Option<u32> {
|
||||
let wanted = base_game_version(mc_version);
|
||||
let loader_fits = |index: &CfFileIndex| {
|
||||
if content_type != ContentType::Mod || !loader.is_modded() {
|
||||
return true;
|
||||
}
|
||||
index
|
||||
.mod_loader
|
||||
.and_then(|raw| cf_loader_to_game(CfLoader::from(raw)))
|
||||
.is_some_and(|other| loader.compatible_with(other))
|
||||
};
|
||||
|
||||
let fitting: Vec<&CfFileIndex> = files
|
||||
.iter()
|
||||
.filter(|index| base_game_version(&index.game_version) == wanted)
|
||||
.filter(|index| loader_fits(index))
|
||||
.collect();
|
||||
|
||||
fitting
|
||||
.iter()
|
||||
.filter(|index| index.release_type == 1)
|
||||
.map(|index| index.file_id)
|
||||
.max()
|
||||
.or_else(|| fitting.iter().map(|index| index.file_id).max())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ pub use modrinth::ModrinthProvider;
|
|||
pub use registry::PackageProviderRegistry;
|
||||
|
||||
use super::types::{
|
||||
Page, ProjectDetail, ProjectSummary, SearchFilters, VersionDetail, VersionLookup,
|
||||
VersionSummary,
|
||||
InstalledPackage, Page, ProjectDetail, ProjectSummary, SearchFilters, VersionDetail,
|
||||
VersionLookup, VersionSummary,
|
||||
};
|
||||
use crate::ctx::ContentCtx;
|
||||
use crate::error::ContentResult;
|
||||
|
|
@ -75,6 +75,14 @@ pub trait PackageProvider: Send + Sync {
|
|||
ctx: &ContentCtx,
|
||||
) -> ContentResult<VersionLookup>;
|
||||
|
||||
async fn latest_for_game_version(
|
||||
&self,
|
||||
packages: &[InstalledPackage],
|
||||
mc_version: &str,
|
||||
loader: oneclient_common::domain::GameLoader,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<std::collections::HashMap<String, VersionDetail>>;
|
||||
|
||||
async fn list_categories(
|
||||
&self,
|
||||
_content_type: oneclient_common::domain::ContentType,
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ use crate::ctx::ContentCtx;
|
|||
use crate::error::ContentResult;
|
||||
use crate::packages::file_identity::FileIdentity;
|
||||
use crate::packages::types::{
|
||||
DependencyKind, GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary,
|
||||
ReleaseType, SearchFilters, VersionDependency, VersionDetail, VersionFile, VersionLookup,
|
||||
VersionSummary,
|
||||
DependencyKind, GalleryImage, InstalledPackage, PackageBody, Page, ProjectDetail,
|
||||
ProjectMember, ProjectSummary, ReleaseType, SearchFilters, VersionDependency, VersionDetail,
|
||||
VersionFile, VersionLookup, VersionSummary,
|
||||
};
|
||||
use oneclient_common::constants::MODRINTH_API_URL;
|
||||
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
|
||||
|
|
@ -370,6 +370,102 @@ impl PackageProvider for ModrinthProvider {
|
|||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(self, packages, ctx))]
|
||||
async fn latest_for_game_version(
|
||||
&self,
|
||||
packages: &[InstalledPackage],
|
||||
mc_version: &str,
|
||||
loader: GameLoader,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<HashMap<String, VersionDetail>> {
|
||||
let mut groups: HashMap<ContentType, Vec<String>> = HashMap::new();
|
||||
for package in packages {
|
||||
groups
|
||||
.entry(package.content_type)
|
||||
.or_default()
|
||||
.push(package.hash.clone());
|
||||
}
|
||||
|
||||
let mut out = HashMap::new();
|
||||
for (content_type, hashes) in groups {
|
||||
let loaders = modrinth_loader_filter(content_type, loader);
|
||||
|
||||
let releases =
|
||||
latest_version_files(ctx, &hashes, mc_version, loaders.as_deref(), true).await?;
|
||||
let missing: Vec<String> = hashes
|
||||
.iter()
|
||||
.filter(|hash| !releases.contains_key(*hash))
|
||||
.cloned()
|
||||
.collect();
|
||||
out.extend(releases);
|
||||
|
||||
if !missing.is_empty() {
|
||||
out.extend(
|
||||
latest_version_files(ctx, &missing, mc_version, loaders.as_deref(), false)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn modrinth_loader_filter(content_type: ContentType, loader: GameLoader) -> Option<Vec<String>> {
|
||||
if content_type != ContentType::Mod || !loader.is_modded() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
[
|
||||
GameLoader::Forge,
|
||||
GameLoader::NeoForge,
|
||||
GameLoader::Quilt,
|
||||
GameLoader::Fabric,
|
||||
GameLoader::Ornithe,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|other| loader.compatible_with(*other))
|
||||
.map(|other| other.modrinth_name().to_string())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn latest_version_files(
|
||||
ctx: &ContentCtx,
|
||||
hashes: &[String],
|
||||
mc_version: &str,
|
||||
loaders: Option<&[String]>,
|
||||
releases_only: bool,
|
||||
) -> ContentResult<HashMap<String, VersionDetail>> {
|
||||
if hashes.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"hashes": hashes,
|
||||
"algorithm": "sha1",
|
||||
"game_versions": [mc_version],
|
||||
});
|
||||
if let Some(loaders) = loaders {
|
||||
body["loaders"] = serde_json::json!(loaders);
|
||||
}
|
||||
if releases_only {
|
||||
body["version_types"] = serde_json::json!(["release"]);
|
||||
}
|
||||
|
||||
let fetched: HashMap<String, ModrinthVersion> = fetch_json(
|
||||
&ctx.net,
|
||||
Method::POST,
|
||||
&v2("/version_files/update"),
|
||||
Some(body),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(fetched
|
||||
.into_iter()
|
||||
.map(|(hash, version)| (polyio::normalize_hash(&hash), version.into()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(client, team_ids))]
|
||||
|
|
|
|||
990
packages/oneclient_content/src/packages/release_migration.rs
Normal file
990
packages/oneclient_content/src/packages/release_migration.rs
Normal file
|
|
@ -0,0 +1,990 @@
|
|||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use chrono::Utc;
|
||||
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
|
||||
use oneclient_db::dao::artifact as artifact_dao;
|
||||
use oneclient_db::dao::cluster_bundle as bundle_dao;
|
||||
use oneclient_db::dao::release_migration_waitlist as waitlist_dao;
|
||||
use oneclient_db::models::{ClusterRow, ReleaseMigrationWaitlistRow, SeenStatus};
|
||||
use oneclient_events::GroupedProgressChild;
|
||||
|
||||
use crate::bundles::{BundlesManager, enabled_bundle_projects};
|
||||
use crate::ctx::ContentCtx;
|
||||
use crate::error::ContentResult;
|
||||
use crate::packages::dependencies::{
|
||||
ResolvedDependency, resolve_one, resolves_dependencies, supports_game_version,
|
||||
};
|
||||
use crate::packages::store::PackageStore;
|
||||
use crate::packages::types::{
|
||||
DependencyKind, InstalledPackage, ProjectDetail, VersionDependency, VersionDetail,
|
||||
};
|
||||
use crate::packages::updates::{Candidate, browser_installed_any};
|
||||
|
||||
pub const MIGRATED_CONTENT_TYPES: [ContentType; 3] = [
|
||||
ContentType::Mod,
|
||||
ContentType::ResourcePack,
|
||||
ContentType::Shader,
|
||||
];
|
||||
|
||||
const DEPENDENCY_DEPTH: usize = 4;
|
||||
|
||||
const SOURCE_CONCURRENCY: usize = 3;
|
||||
const DEPENDENCY_CONCURRENCY: usize = 6;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReleaseMigrationPackage {
|
||||
pub source_hash: String,
|
||||
pub enabled: bool,
|
||||
pub provider: ProviderId,
|
||||
pub project_id: String,
|
||||
pub content_type: ContentType,
|
||||
pub display_name: String,
|
||||
pub version: VersionDetail,
|
||||
pub version_name: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum SkipReason {
|
||||
NoCompatibleVersion,
|
||||
ProviderUnavailable,
|
||||
MissingDependency,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReleaseMigrationSkip {
|
||||
pub source_hash: String,
|
||||
pub enabled: bool,
|
||||
pub provider: ProviderId,
|
||||
pub project_id: String,
|
||||
pub content_type: ContentType,
|
||||
pub display_name: String,
|
||||
pub reason: SkipReason,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReleaseMigrationDependency {
|
||||
pub provider: ProviderId,
|
||||
pub project: ProjectDetail,
|
||||
pub version: VersionDetail,
|
||||
pub required_by: Vec<String>,
|
||||
}
|
||||
|
||||
impl ReleaseMigrationDependency {
|
||||
#[must_use]
|
||||
pub fn version_name(&self) -> String {
|
||||
version_label(&self.version)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn size(&self) -> u64 {
|
||||
self.version.primary_file().map_or(0, |file| file.size)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_needed_by(&self, selected_hashes: &HashSet<String>) -> bool {
|
||||
self.required_by
|
||||
.iter()
|
||||
.any(|hash| selected_hashes.contains(hash))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn enabled_for(&self, packages: &[ReleaseMigrationPackage]) -> bool {
|
||||
packages
|
||||
.iter()
|
||||
.any(|package| package.enabled && self.required_by.contains(&package.source_hash))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ReleaseMigrationPlan {
|
||||
pub source_cluster_id: i64,
|
||||
pub target_cluster_id: i64,
|
||||
pub packages: Vec<ReleaseMigrationPackage>,
|
||||
pub dependencies: Vec<ReleaseMigrationDependency>,
|
||||
pub unavailable: Vec<ReleaseMigrationSkip>,
|
||||
pub unreachable: bool,
|
||||
}
|
||||
|
||||
impl ReleaseMigrationPlan {
|
||||
#[must_use]
|
||||
pub fn offered(&self, content_type: ContentType) -> usize {
|
||||
self.packages
|
||||
.iter()
|
||||
.filter(|package| package.content_type == content_type)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
fn version_label(version: &VersionDetail) -> String {
|
||||
if version.version_number.is_empty() {
|
||||
version.name.clone()
|
||||
} else {
|
||||
version.version_number.clone()
|
||||
}
|
||||
}
|
||||
|
||||
async fn migratable_candidates(cluster_id: i64, ctx: &ContentCtx) -> ContentResult<Vec<Candidate>> {
|
||||
let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?;
|
||||
let tracked = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?;
|
||||
let bundle_projects: HashSet<String> = tracked
|
||||
.iter()
|
||||
.filter_map(|row| row.package_id.clone())
|
||||
.collect();
|
||||
let bundle_hashes: HashSet<String> = tracked.into_iter().map(|row| row.hash).collect();
|
||||
|
||||
Ok(
|
||||
browser_installed_any(&linked, &bundle_hashes, &bundle_projects)
|
||||
.into_iter()
|
||||
.filter(|candidate| MIGRATED_CONTENT_TYPES.contains(&candidate.content_type))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(ctx))]
|
||||
pub async fn has_migratable_packages(cluster_id: i64, ctx: &ContentCtx) -> ContentResult<bool> {
|
||||
Ok(!migratable_candidates(cluster_id, ctx).await?.is_empty())
|
||||
}
|
||||
|
||||
async fn target_projects(
|
||||
target_cluster_id: i64,
|
||||
bundles: &BundlesManager,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<HashSet<String>> {
|
||||
let mut projects: HashSet<String> = PackageStore::list_linked_artifacts(target_cluster_id, ctx)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|linked| linked.project_id)
|
||||
.collect();
|
||||
projects.extend(
|
||||
bundle_dao::list_bundle_tracked(&ctx.db, target_cluster_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|row| row.package_id),
|
||||
);
|
||||
match enabled_bundle_projects(target_cluster_id, bundles, ctx).await {
|
||||
Ok(from_bundles) => projects.extend(from_bundles),
|
||||
Err(err) => tracing::warn!(
|
||||
target_cluster_id,
|
||||
error = %err,
|
||||
"could not read the target's bundles, treating only installed packages as present"
|
||||
),
|
||||
}
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
fn fits_target(
|
||||
version: &VersionDetail,
|
||||
content_type: ContentType,
|
||||
mc_version: &str,
|
||||
loader: GameLoader,
|
||||
) -> bool {
|
||||
if version.primary_file().is_none()
|
||||
|| !supports_game_version(&version.game_versions, mc_version)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
content_type != ContentType::Mod
|
||||
|| !loader.is_modded()
|
||||
|| version.loaders.is_empty()
|
||||
|| version
|
||||
.loaders
|
||||
.iter()
|
||||
.any(|other| loader.compatible_with(*other))
|
||||
}
|
||||
|
||||
type Lookup = Option<ResolvedDependency>;
|
||||
type LookupCells = HashMap<(ProviderId, String), Arc<OnceCell<Lookup>>>;
|
||||
|
||||
struct Unreachable;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DependencyCache {
|
||||
entries: Mutex<LookupCells>,
|
||||
}
|
||||
|
||||
fn dependency_key(dep: &VersionDependency) -> Option<String> {
|
||||
dep.version_id
|
||||
.as_ref()
|
||||
.map(|version_id| format!("version:{version_id}"))
|
||||
.or_else(|| {
|
||||
dep.project_id
|
||||
.as_ref()
|
||||
.map(|project_id| format!("project:{project_id}"))
|
||||
})
|
||||
}
|
||||
|
||||
impl DependencyCache {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn cell(&self, provider_id: ProviderId, key: String) -> Arc<OnceCell<Lookup>> {
|
||||
Arc::clone(
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry((provider_id, key))
|
||||
.or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn lookup(
|
||||
&self,
|
||||
provider_id: ProviderId,
|
||||
dep: &VersionDependency,
|
||||
target: &ClusterRow,
|
||||
ctx: &ContentCtx,
|
||||
) -> Result<Lookup, Unreachable> {
|
||||
let Some(key) = dependency_key(dep) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let cell = self.cell(provider_id, key);
|
||||
cell.get_or_try_init(|| async {
|
||||
let provider = match ctx.providers.get(provider_id) {
|
||||
Ok(provider) => provider,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
match resolve_one(provider, dep, target, ctx).await {
|
||||
Ok(resolved) => Ok(resolved),
|
||||
Err(err) if err.is_transient() => Err(Unreachable),
|
||||
Err(err) => {
|
||||
tracing::warn!(?dep, %err, "could not resolve a dependency for release migration");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.cloned()
|
||||
}
|
||||
|
||||
async fn resolve(
|
||||
&self,
|
||||
provider_id: ProviderId,
|
||||
root: &VersionDetail,
|
||||
target: &ClusterRow,
|
||||
present: &HashSet<String>,
|
||||
ctx: &ContentCtx,
|
||||
) -> Result<Option<Vec<ResolvedDependency>>, Unreachable> {
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
seen.insert(root.project_id.clone());
|
||||
|
||||
let mut queue: VecDeque<(VersionDependency, usize)> = root
|
||||
.dependencies
|
||||
.iter()
|
||||
.filter(|dep| dep.kind == DependencyKind::Required)
|
||||
.map(|dep| (dep.clone(), 1))
|
||||
.collect();
|
||||
|
||||
let mut found = Vec::new();
|
||||
while let Some((dep, depth)) = queue.pop_front() {
|
||||
if let Some(project_id) = &dep.project_id
|
||||
&& (seen.contains(project_id) || present.contains(project_id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(resolved) = self.lookup(provider_id, &dep, target, ctx).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let project_id = resolved.version.project_id.clone();
|
||||
if present.contains(&project_id) || !seen.insert(project_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if depth < DEPENDENCY_DEPTH {
|
||||
for next in resolved
|
||||
.version
|
||||
.dependencies
|
||||
.iter()
|
||||
.filter(|next| next.kind == DependencyKind::Required)
|
||||
{
|
||||
queue.push_back((next.clone(), depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
found.push(resolved);
|
||||
}
|
||||
|
||||
Ok(Some(found))
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(source_cluster_ids, bundles, ctx))]
|
||||
pub async fn plan_release_migrations(
|
||||
source_cluster_ids: &[i64],
|
||||
target_cluster_id: i64,
|
||||
bundles: &BundlesManager,
|
||||
ctx: &ContentCtx,
|
||||
) -> Vec<(i64, ContentResult<ReleaseMigrationPlan>)> {
|
||||
let cache = DependencyCache::new();
|
||||
|
||||
futures_util::stream::iter(source_cluster_ids.iter().copied().map(|source_cluster_id| {
|
||||
let cache = &cache;
|
||||
async move {
|
||||
let plan = plan_release_migration_cached(
|
||||
source_cluster_id,
|
||||
target_cluster_id,
|
||||
cache,
|
||||
bundles,
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
(source_cluster_id, plan)
|
||||
}
|
||||
}))
|
||||
.buffer_unordered(SOURCE_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(cache, bundles, ctx))]
|
||||
async fn plan_release_migration_cached(
|
||||
source_cluster_id: i64,
|
||||
target_cluster_id: i64,
|
||||
cache: &DependencyCache,
|
||||
bundles: &BundlesManager,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<ReleaseMigrationPlan> {
|
||||
let target = PackageStore::get_cluster(target_cluster_id, ctx).await?;
|
||||
let present = target_projects(target_cluster_id, bundles, ctx).await?;
|
||||
|
||||
let candidates: Vec<Candidate> = migratable_candidates(source_cluster_id, ctx)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|candidate| !present.contains(&candidate.project_id))
|
||||
.collect();
|
||||
|
||||
let evaluation = evaluate_candidates(candidates, &target, &present, cache, ctx).await;
|
||||
|
||||
let mut plan = ReleaseMigrationPlan {
|
||||
source_cluster_id,
|
||||
target_cluster_id,
|
||||
packages: evaluation.packages,
|
||||
dependencies: evaluation.dependencies,
|
||||
unavailable: evaluation.unavailable,
|
||||
unreachable: evaluation.unreachable,
|
||||
};
|
||||
|
||||
plan.packages
|
||||
.sort_by_key(|package| package.display_name.to_lowercase());
|
||||
plan.dependencies
|
||||
.sort_by_key(|dependency| dependency.project.name.to_lowercase());
|
||||
plan.unavailable
|
||||
.sort_by_key(|skip| skip.display_name.to_lowercase());
|
||||
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Evaluation {
|
||||
packages: Vec<ReleaseMigrationPackage>,
|
||||
dependencies: Vec<ReleaseMigrationDependency>,
|
||||
unavailable: Vec<ReleaseMigrationSkip>,
|
||||
unreachable: bool,
|
||||
}
|
||||
|
||||
async fn evaluate_candidates(
|
||||
candidates: Vec<Candidate>,
|
||||
target: &ClusterRow,
|
||||
present: &HashSet<String>,
|
||||
cache: &DependencyCache,
|
||||
ctx: &ContentCtx,
|
||||
) -> Evaluation {
|
||||
let loader = GameLoader::from_repr(target.mc_loader as u8).unwrap_or(GameLoader::Vanilla);
|
||||
let mut evaluation = Evaluation::default();
|
||||
|
||||
let mut by_provider: HashMap<ProviderId, Vec<Candidate>> = HashMap::new();
|
||||
for candidate in candidates {
|
||||
by_provider
|
||||
.entry(candidate.provider)
|
||||
.or_default()
|
||||
.push(candidate);
|
||||
}
|
||||
|
||||
let mut offered: Vec<ReleaseMigrationPackage> = Vec::new();
|
||||
|
||||
for (provider_id, candidates) in by_provider {
|
||||
let installed: Vec<InstalledPackage> = candidates
|
||||
.iter()
|
||||
.map(|candidate| InstalledPackage {
|
||||
hash: candidate.hash.clone(),
|
||||
project_id: candidate.project_id.clone(),
|
||||
content_type: candidate.content_type,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let found = match ctx.providers.get(provider_id) {
|
||||
Ok(provider) => {
|
||||
provider
|
||||
.latest_for_game_version(&installed, &target.mc_version, loader, ctx)
|
||||
.await
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
|
||||
let found = match found {
|
||||
Ok(found) => found,
|
||||
Err(err) if err.is_transient() => {
|
||||
tracing::warn!(provider = ?provider_id, error = %err, "release migration check could not reach the provider");
|
||||
evaluation.unreachable = true;
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(provider = ?provider_id, error = %err, "release migration check failed for the provider");
|
||||
evaluation
|
||||
.unavailable
|
||||
.extend(
|
||||
candidates
|
||||
.into_iter()
|
||||
.map(|candidate| ReleaseMigrationSkip {
|
||||
source_hash: candidate.hash,
|
||||
enabled: candidate.enabled,
|
||||
provider: provider_id,
|
||||
project_id: candidate.project_id,
|
||||
content_type: candidate.content_type,
|
||||
display_name: candidate.display_name,
|
||||
reason: SkipReason::ProviderUnavailable,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for candidate in candidates {
|
||||
let version = found.get(&candidate.hash).filter(|version| {
|
||||
fits_target(version, candidate.content_type, &target.mc_version, loader)
|
||||
});
|
||||
|
||||
match version {
|
||||
Some(version) => offered.push(ReleaseMigrationPackage {
|
||||
source_hash: candidate.hash,
|
||||
enabled: candidate.enabled,
|
||||
provider: provider_id,
|
||||
project_id: candidate.project_id,
|
||||
content_type: candidate.content_type,
|
||||
display_name: candidate.display_name,
|
||||
version: version.clone(),
|
||||
version_name: version_label(version),
|
||||
size: version.primary_file().map_or(0, |file| file.size),
|
||||
}),
|
||||
None => evaluation.unavailable.push(ReleaseMigrationSkip {
|
||||
source_hash: candidate.hash,
|
||||
enabled: candidate.enabled,
|
||||
provider: provider_id,
|
||||
project_id: candidate.project_id,
|
||||
content_type: candidate.content_type,
|
||||
display_name: candidate.display_name,
|
||||
reason: SkipReason::NoCompatibleVersion,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if evaluation.unreachable {
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
let resolved_packages =
|
||||
futures_util::stream::iter(offered.into_iter().map(|package| async move {
|
||||
let resolved = if resolves_dependencies(package.content_type) {
|
||||
cache
|
||||
.resolve(package.provider, &package.version, target, present, ctx)
|
||||
.await
|
||||
} else {
|
||||
Ok(Some(Vec::new()))
|
||||
};
|
||||
(package, resolved)
|
||||
}))
|
||||
.buffer_unordered(DEPENDENCY_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
let mut dependencies: Vec<ReleaseMigrationDependency> = Vec::new();
|
||||
for (package, resolved) in resolved_packages {
|
||||
let resolved = match resolved {
|
||||
Ok(resolved) => resolved,
|
||||
Err(Unreachable) => {
|
||||
evaluation.unreachable = true;
|
||||
return evaluation;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(resolved) = resolved else {
|
||||
evaluation.unavailable.push(ReleaseMigrationSkip {
|
||||
source_hash: package.source_hash,
|
||||
enabled: package.enabled,
|
||||
provider: package.provider,
|
||||
project_id: package.project_id,
|
||||
content_type: package.content_type,
|
||||
display_name: package.display_name,
|
||||
reason: SkipReason::MissingDependency,
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
||||
for dependency in resolved {
|
||||
match dependencies.iter_mut().find(|existing| {
|
||||
existing.provider == package.provider
|
||||
&& existing.project.id == dependency.project.id
|
||||
}) {
|
||||
Some(existing) => existing.required_by.push(package.source_hash.clone()),
|
||||
None => dependencies.push(ReleaseMigrationDependency {
|
||||
provider: package.provider,
|
||||
project: dependency.project,
|
||||
version: dependency.version,
|
||||
required_by: vec![package.source_hash.clone()],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
evaluation.packages.push(package);
|
||||
}
|
||||
|
||||
let libraries: HashSet<String> = evaluation
|
||||
.packages
|
||||
.iter()
|
||||
.filter(|package| {
|
||||
dependencies.iter().any(|dependency| {
|
||||
dependency.provider == package.provider
|
||||
&& dependency.project.id == package.project_id
|
||||
})
|
||||
})
|
||||
.map(|package| package.source_hash.clone())
|
||||
.collect();
|
||||
|
||||
evaluation
|
||||
.packages
|
||||
.retain(|package| !libraries.contains(&package.source_hash));
|
||||
for dependency in &mut dependencies {
|
||||
dependency
|
||||
.required_by
|
||||
.retain(|hash| !libraries.contains(hash));
|
||||
}
|
||||
dependencies.retain(|dependency| !dependency.required_by.is_empty());
|
||||
|
||||
evaluation.dependencies = dependencies;
|
||||
evaluation
|
||||
}
|
||||
|
||||
pub const WAITLIST_DAYS: i64 = 30;
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(skips, ctx))]
|
||||
pub async fn add_to_waitlist(
|
||||
target_cluster_id: i64,
|
||||
skips: &[ReleaseMigrationSkip],
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<()> {
|
||||
let now = Utc::now();
|
||||
let added_at = now.to_rfc3339();
|
||||
let expires_at = (now + chrono::Duration::days(WAITLIST_DAYS)).to_rfc3339();
|
||||
|
||||
for skip in skips {
|
||||
waitlist_dao::upsert(
|
||||
&ctx.db,
|
||||
target_cluster_id,
|
||||
skip.provider as i64,
|
||||
&skip.project_id,
|
||||
skip.content_type as i64,
|
||||
&skip.source_hash,
|
||||
&skip.display_name,
|
||||
&added_at,
|
||||
&expires_at,
|
||||
skip.enabled,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WaitlistInstall {
|
||||
pub cluster_id: i64,
|
||||
pub cluster_name: String,
|
||||
pub mc_version: String,
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(bundles, is_running, ctx))]
|
||||
pub async fn process_waitlist(
|
||||
bundles: &BundlesManager,
|
||||
is_running: impl Fn(i64) -> bool,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<Vec<WaitlistInstall>> {
|
||||
let expired = waitlist_dao::delete_expired(&ctx.db, &Utc::now().to_rfc3339()).await?;
|
||||
if expired > 0 {
|
||||
tracing::info!(
|
||||
expired,
|
||||
"dropped expired release migration waitlist entries"
|
||||
);
|
||||
}
|
||||
|
||||
let mut by_cluster: std::collections::BTreeMap<i64, Vec<ReleaseMigrationWaitlistRow>> =
|
||||
std::collections::BTreeMap::new();
|
||||
let rows = waitlist_dao::list_all(&ctx.db).await?;
|
||||
tracing::info!(
|
||||
entries = rows.len(),
|
||||
expired,
|
||||
"checking the release migration waitlist for new builds"
|
||||
);
|
||||
for row in rows {
|
||||
by_cluster
|
||||
.entry(row.target_cluster_id)
|
||||
.or_default()
|
||||
.push(row);
|
||||
}
|
||||
|
||||
let cache = DependencyCache::new();
|
||||
let mut installs = Vec::new();
|
||||
|
||||
for (cluster_id, rows) in by_cluster {
|
||||
if is_running(cluster_id) {
|
||||
tracing::info!(
|
||||
cluster_id,
|
||||
waiting = rows.len(),
|
||||
"skipping the waitlist check for a running cluster"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let target = match PackageStore::get_cluster(cluster_id, ctx).await {
|
||||
Ok(target) => target,
|
||||
Err(err) => {
|
||||
tracing::warn!(cluster_id, %err, "could not read a waitlisted cluster, skipping it");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let present = match target_projects(cluster_id, bundles, ctx).await {
|
||||
Ok(present) => present,
|
||||
Err(err) => {
|
||||
tracing::warn!(cluster_id, %err, "could not read a waitlisted cluster's packages, skipping it");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
for row in rows {
|
||||
if present.contains(&row.project_id) {
|
||||
tracing::info!(
|
||||
cluster_id,
|
||||
package = %row.display_name,
|
||||
"waitlisted package is already in the cluster, dropping it"
|
||||
);
|
||||
waitlist_dao::delete(&ctx.db, cluster_id, row.provider, &row.project_id).await?;
|
||||
continue;
|
||||
}
|
||||
let (Some(provider), Some(content_type)) = (
|
||||
ProviderId::from_repr(row.provider as u8),
|
||||
ContentType::from_repr(row.content_type as u8),
|
||||
) else {
|
||||
waitlist_dao::delete(&ctx.db, cluster_id, row.provider, &row.project_id).await?;
|
||||
continue;
|
||||
};
|
||||
candidates.push(Candidate {
|
||||
hash: row.source_hash,
|
||||
provider,
|
||||
content_type,
|
||||
project_id: row.project_id,
|
||||
version_id: String::new(),
|
||||
display_name: row.display_name,
|
||||
display_version: String::new(),
|
||||
published_at: None,
|
||||
enabled: row.enabled != 0,
|
||||
});
|
||||
}
|
||||
if candidates.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let waitlisted: HashSet<(ProviderId, String)> = candidates
|
||||
.iter()
|
||||
.map(|candidate| (candidate.provider, candidate.project_id.clone()))
|
||||
.collect();
|
||||
|
||||
let checking: Vec<String> = candidates
|
||||
.iter()
|
||||
.map(|candidate| candidate.display_name.clone())
|
||||
.collect();
|
||||
tracing::info!(
|
||||
cluster_id,
|
||||
cluster = %target.name,
|
||||
mc_version = %target.mc_version,
|
||||
packages = ?checking,
|
||||
"checking waitlisted packages for {} builds",
|
||||
target.mc_version
|
||||
);
|
||||
|
||||
let evaluation = evaluate_candidates(candidates, &target, &present, &cache, ctx).await;
|
||||
if evaluation.unreachable {
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
"a provider could not be reached, keeping the waitlist for the next launch"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
cluster_id,
|
||||
mc_version = %target.mc_version,
|
||||
available = ?evaluation
|
||||
.packages
|
||||
.iter()
|
||||
.map(|package| format!("{} {}", package.display_name, package.version_name))
|
||||
.collect::<Vec<_>>(),
|
||||
dependencies = ?evaluation
|
||||
.dependencies
|
||||
.iter()
|
||||
.map(|dependency| dependency.project.name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
still_waiting = ?evaluation
|
||||
.unavailable
|
||||
.iter()
|
||||
.map(|skip| format!("{} ({:?})", skip.display_name, skip.reason))
|
||||
.collect::<Vec<_>>(),
|
||||
"waitlist check finished for {} builds",
|
||||
target.mc_version
|
||||
);
|
||||
|
||||
let mut names = Vec::new();
|
||||
let mut blocked: HashSet<String> = HashSet::new();
|
||||
|
||||
for dependency in &evaluation.dependencies {
|
||||
let enabled = dependency.enabled_for(&evaluation.packages);
|
||||
match apply_release_migration_dependency(cluster_id, dependency, enabled, None, ctx)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
if waitlisted.contains(&(dependency.provider, dependency.project.id.clone())) {
|
||||
waitlist_dao::delete(
|
||||
&ctx.db,
|
||||
cluster_id,
|
||||
dependency.provider as i64,
|
||||
&dependency.project.id,
|
||||
)
|
||||
.await?;
|
||||
names.push(dependency.project.name.clone());
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(dependency = %dependency.project.name, %err, "waitlisted package dependency failed to install");
|
||||
blocked.extend(dependency.required_by.iter().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for package in &evaluation.packages {
|
||||
if blocked.contains(&package.source_hash) {
|
||||
continue;
|
||||
}
|
||||
match apply_release_migration_package(cluster_id, package, None, ctx).await {
|
||||
Ok(_) => {
|
||||
waitlist_dao::delete(
|
||||
&ctx.db,
|
||||
cluster_id,
|
||||
package.provider as i64,
|
||||
&package.project_id,
|
||||
)
|
||||
.await?;
|
||||
names.push(package.display_name.clone());
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(package = %package.display_name, %err, "waitlisted package failed to install")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !names.is_empty() {
|
||||
tracing::info!(
|
||||
cluster_id,
|
||||
cluster = %target.name,
|
||||
installed = ?names,
|
||||
"installed waitlisted packages that gained a {} build",
|
||||
target.mc_version
|
||||
);
|
||||
installs.push(WaitlistInstall {
|
||||
cluster_id,
|
||||
cluster_name: target.name.clone(),
|
||||
mc_version: target.mc_version.clone(),
|
||||
names,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(installs)
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(dependency, child, ctx), fields(project_id = %dependency.project.id))]
|
||||
pub async fn apply_release_migration_dependency(
|
||||
target_cluster_id: i64,
|
||||
dependency: &ReleaseMigrationDependency,
|
||||
enabled: bool,
|
||||
child: Option<&GroupedProgressChild>,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<String> {
|
||||
let (installed, _) = PackageStore::install_to_cluster(
|
||||
dependency.provider,
|
||||
&dependency.project,
|
||||
&dependency.version,
|
||||
target_cluster_id,
|
||||
true,
|
||||
false,
|
||||
child,
|
||||
ctx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
artifact_dao::set_seen_status(&ctx.db, target_cluster_id, &installed.hash, SeenStatus::New)
|
||||
.await?;
|
||||
|
||||
if !enabled {
|
||||
PackageStore::set_artifact_enabled_to(target_cluster_id, &installed.hash, false, ctx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(installed.hash)
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(package, child, ctx), fields(project_id = %package.project_id))]
|
||||
pub async fn apply_release_migration_package(
|
||||
target_cluster_id: i64,
|
||||
package: &ReleaseMigrationPackage,
|
||||
child: Option<&GroupedProgressChild>,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<String> {
|
||||
let project = ctx
|
||||
.providers
|
||||
.get(package.provider)?
|
||||
.get_project(&package.project_id, ctx)
|
||||
.await?;
|
||||
|
||||
let (installed, _) = PackageStore::install_to_cluster(
|
||||
package.provider,
|
||||
&project,
|
||||
&package.version,
|
||||
target_cluster_id,
|
||||
true,
|
||||
false,
|
||||
child,
|
||||
ctx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
artifact_dao::set_seen_status(&ctx.db, target_cluster_id, &installed.hash, SeenStatus::New)
|
||||
.await?;
|
||||
|
||||
if !package.enabled {
|
||||
PackageStore::set_artifact_enabled_to(target_cluster_id, &installed.hash, false, ctx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(installed.hash)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Utc;
|
||||
|
||||
use super::*;
|
||||
use crate::packages::types::VersionFile;
|
||||
|
||||
fn version(game_versions: &[&str], loaders: Vec<GameLoader>) -> VersionDetail {
|
||||
VersionDetail {
|
||||
version_id: "v".into(),
|
||||
project_id: "p".into(),
|
||||
name: "name".into(),
|
||||
version_number: "1.0".into(),
|
||||
changelog: None,
|
||||
game_versions: game_versions.iter().map(|v| (*v).to_string()).collect(),
|
||||
loaders,
|
||||
published: Utc::now(),
|
||||
downloads: 0,
|
||||
files: vec![VersionFile {
|
||||
sha1: "a".into(),
|
||||
url: "u".into(),
|
||||
file_name: "f.jar".into(),
|
||||
primary: true,
|
||||
size: 1,
|
||||
fingerprint: None,
|
||||
}],
|
||||
dependencies: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_for_another_loader_is_not_offered() {
|
||||
let forge = version(&["26.3"], vec![GameLoader::Forge]);
|
||||
assert!(!fits_target(
|
||||
&forge,
|
||||
ContentType::Mod,
|
||||
"26.3",
|
||||
GameLoader::Fabric
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_for_an_older_game_version_is_not_offered() {
|
||||
let old = version(&["26.2"], vec![GameLoader::Fabric]);
|
||||
assert!(!fits_target(
|
||||
&old,
|
||||
ContentType::Mod,
|
||||
"26.3",
|
||||
GameLoader::Fabric
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_packs_ignore_the_cluster_loader() {
|
||||
let pack = version(&["26.3"], vec![GameLoader::Vanilla]);
|
||||
assert!(fits_target(
|
||||
&pack,
|
||||
ContentType::ResourcePack,
|
||||
"26.3",
|
||||
GameLoader::Fabric
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_without_files_is_not_offered() {
|
||||
let mut empty = version(&["26.3"], vec![GameLoader::Fabric]);
|
||||
empty.files.clear();
|
||||
assert!(!fits_target(
|
||||
&empty,
|
||||
ContentType::Mod,
|
||||
"26.3",
|
||||
GameLoader::Fabric
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packages_keep_a_stable_order_however_the_checks_finish() {
|
||||
let mut packages = vec!["Zoomify", "blur", "Sodium"];
|
||||
packages.sort_by_key(|name| name.to_lowercase());
|
||||
assert_eq!(packages, vec!["blur", "Sodium", "Zoomify"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_versions_and_projects_cache_separately() {
|
||||
let pinned = VersionDependency {
|
||||
project_id: Some("fabric-api".into()),
|
||||
version_id: Some("v1".into()),
|
||||
kind: DependencyKind::Required,
|
||||
};
|
||||
let loose = VersionDependency {
|
||||
project_id: Some("fabric-api".into()),
|
||||
version_id: None,
|
||||
kind: DependencyKind::Required,
|
||||
};
|
||||
assert_eq!(dependency_key(&pinned).as_deref(), Some("version:v1"));
|
||||
assert_eq!(
|
||||
dependency_key(&loose).as_deref(),
|
||||
Some("project:fabric-api")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -232,6 +232,13 @@ pub struct ProviderReleaseInfo {
|
|||
}
|
||||
|
||||
pub type VersionLookup = HashMap<String, VersionDetail>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InstalledPackage {
|
||||
pub hash: String,
|
||||
pub project_id: String,
|
||||
pub content_type: ContentType,
|
||||
}
|
||||
pub type ProviderVersionLookup = HashMap<String, (ProviderId, VersionDetail)>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use std::collections::HashSet;
|
|||
use chrono::{DateTime, Utc};
|
||||
use futures_util::StreamExt;
|
||||
|
||||
use oneclient_common::domain::ProviderId;
|
||||
use oneclient_common::domain::{ContentType, ProviderId};
|
||||
use oneclient_db::dao::artifact as artifact_dao;
|
||||
use oneclient_db::dao::browser_package_update as update_dao;
|
||||
use oneclient_db::dao::cluster_bundle as bundle_dao;
|
||||
|
|
@ -81,25 +81,37 @@ impl BrowserUpdateCheck {
|
|||
}
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
hash: String,
|
||||
provider: ProviderId,
|
||||
project_id: String,
|
||||
version_id: String,
|
||||
display_name: String,
|
||||
display_version: String,
|
||||
published_at: Option<DateTime<Utc>>,
|
||||
pub(crate) struct Candidate {
|
||||
pub(crate) hash: String,
|
||||
pub(crate) provider: ProviderId,
|
||||
pub(crate) content_type: ContentType,
|
||||
pub(crate) project_id: String,
|
||||
pub(crate) version_id: String,
|
||||
pub(crate) display_name: String,
|
||||
pub(crate) display_version: String,
|
||||
pub(crate) published_at: Option<DateTime<Utc>>,
|
||||
pub(crate) enabled: bool,
|
||||
}
|
||||
|
||||
/// A missing bundle tracking row is the marker for "the user added this themselves"
|
||||
fn browser_installed(
|
||||
pub(crate) fn browser_installed(
|
||||
linked: &[LinkedArtifactInfo],
|
||||
bundle_hashes: &HashSet<String>,
|
||||
bundle_projects: &HashSet<String>,
|
||||
) -> Vec<Candidate> {
|
||||
browser_installed_any(linked, bundle_hashes, bundle_projects)
|
||||
.into_iter()
|
||||
.filter(|candidate| candidate.enabled)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn browser_installed_any(
|
||||
linked: &[LinkedArtifactInfo],
|
||||
bundle_hashes: &HashSet<String>,
|
||||
bundle_projects: &HashSet<String>,
|
||||
) -> Vec<Candidate> {
|
||||
linked
|
||||
.iter()
|
||||
.filter(|info| info.enabled)
|
||||
.filter(|info| !bundle_hashes.contains(&info.hash))
|
||||
.filter(|info| {
|
||||
info.project_id
|
||||
|
|
@ -114,6 +126,7 @@ fn browser_installed(
|
|||
Some(Candidate {
|
||||
hash: info.hash.clone(),
|
||||
provider,
|
||||
content_type: info.content_type,
|
||||
project_id: info.project_id.clone()?,
|
||||
version_id: info.version_id.clone()?,
|
||||
display_name: info
|
||||
|
|
@ -122,6 +135,7 @@ fn browser_installed(
|
|||
.unwrap_or_else(|| info.file_name.clone()),
|
||||
display_version: info.display_version.clone().unwrap_or_default(),
|
||||
published_at: info.published_at.as_deref().and_then(parse_published),
|
||||
enabled: info.enabled,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
|
|
@ -356,7 +370,7 @@ pub async fn apply_browser_package_update(
|
|||
.await?
|
||||
.map(|link| link.enabled != 0);
|
||||
|
||||
install_new_dependencies(update, &project, &version, ctx).await;
|
||||
install_new_dependencies(update.provider, update.cluster_id, &project, &version, ctx).await;
|
||||
|
||||
// Compatibility is not re-checked
|
||||
// a provider disagreeing at install time would strand the user on a build
|
||||
|
|
@ -399,9 +413,10 @@ pub async fn apply_browser_package_update(
|
|||
Ok(installed.hash)
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(update, project, version, ctx), fields(cluster_id = update.cluster_id))]
|
||||
async fn install_new_dependencies(
|
||||
update: &BrowserPackageUpdate,
|
||||
#[tracing::instrument(level = "debug", skip(project, version, ctx))]
|
||||
pub(crate) async fn install_new_dependencies(
|
||||
provider: ProviderId,
|
||||
cluster_id: i64,
|
||||
project: &crate::packages::types::ProjectDetail,
|
||||
version: &crate::packages::types::VersionDetail,
|
||||
ctx: &ContentCtx,
|
||||
|
|
@ -410,8 +425,7 @@ async fn install_new_dependencies(
|
|||
return;
|
||||
}
|
||||
|
||||
let resolution = match resolve_required(update.provider, version, update.cluster_id, ctx).await
|
||||
{
|
||||
let resolution = match resolve_required(provider, version, cluster_id, ctx).await {
|
||||
Ok(resolution) => resolution,
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "dependency resolution failed, updating the package alone");
|
||||
|
|
@ -425,10 +439,10 @@ async fn install_new_dependencies(
|
|||
|
||||
for dependency in &resolution.install {
|
||||
match PackageStore::install_to_cluster(
|
||||
update.provider,
|
||||
provider,
|
||||
&dependency.project,
|
||||
&dependency.version,
|
||||
update.cluster_id,
|
||||
cluster_id,
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
|
|
@ -443,7 +457,7 @@ async fn install_new_dependencies(
|
|||
);
|
||||
if let Err(err) = artifact_dao::set_seen_status(
|
||||
&ctx.db,
|
||||
update.cluster_id,
|
||||
cluster_id,
|
||||
&artifact.hash,
|
||||
SeenStatus::New,
|
||||
)
|
||||
|
|
@ -464,7 +478,6 @@ async fn install_new_dependencies(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oneclient_common::domain::ContentType;
|
||||
|
||||
fn at(raw: &str) -> DateTime<Utc> {
|
||||
parse_published(raw).expect("test dates are rfc 3339")
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
301
packages/oneclient_core/src/clusters/release_migration.rs
Normal file
301
packages/oneclient_core/src/clusters/release_migration.rs
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
use std::cmp::Reverse;
|
||||
use std::str::FromStr;
|
||||
|
||||
use oneclient_common::domain::GameLoader;
|
||||
|
||||
use oneclient_cluster::Cluster;
|
||||
use oneclient_common::version::parse_mc_version;
|
||||
use oneclient_content::packages::release_migration::has_migratable_packages;
|
||||
|
||||
use crate::LauncherResult;
|
||||
use crate::state::LauncherState;
|
||||
use crate::versions::{ReleaseTarget, RemoteMigration};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReleaseMigrationOffer {
|
||||
pub release: ReleaseTarget,
|
||||
pub target: Cluster,
|
||||
pub sources: Vec<Cluster>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum OfferLookup {
|
||||
MissingCluster,
|
||||
NoSources,
|
||||
Offer(Box<ReleaseMigrationOffer>),
|
||||
}
|
||||
|
||||
fn version_order(mc_version: &str) -> Option<(u32, u32, u32)> {
|
||||
let parsed = parse_mc_version(mc_version)?;
|
||||
Some((
|
||||
parsed.major,
|
||||
parsed.minor.unwrap_or(0),
|
||||
parsed.patch.unwrap_or(0),
|
||||
))
|
||||
}
|
||||
|
||||
fn source_rank(
|
||||
target: (u32, u32, u32),
|
||||
source: (u32, u32, u32),
|
||||
) -> (bool, Reverse<(u32, u32, u32)>) {
|
||||
(source > target, Reverse(source))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn can_migrate_manually(source: GameLoader, target: GameLoader) -> bool {
|
||||
source == target || (source == GameLoader::Fabric && target == GameLoader::Ornithe)
|
||||
}
|
||||
|
||||
fn is_migration_destination(target: &ReleaseTarget, rules: &[RemoteMigration]) -> bool {
|
||||
rules.iter().any(|rule| {
|
||||
rule.to.mc_version == target.mc_version
|
||||
&& GameLoader::from_str(&rule.from.loader).is_ok_and(|loader| loader == target.loader)
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn record_new_versions(state: &LauncherState) -> LauncherResult<()> {
|
||||
let rules = state.versions.migrations().await;
|
||||
let mut added: Vec<ReleaseTarget> = Vec::new();
|
||||
for target in state.versions.take_added_versions() {
|
||||
if is_migration_destination(&target, &rules) {
|
||||
continue;
|
||||
}
|
||||
if !state.versions.shows_initial_migration(&target).await {
|
||||
tracing::info!(
|
||||
mc_version = %target.mc_version,
|
||||
loader = %target.loader,
|
||||
"new version opts out of the initial migration offer"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
added.push(target);
|
||||
}
|
||||
if added.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let updated = {
|
||||
let mut settings = state.settings.write();
|
||||
for target in &added {
|
||||
let key = target.key();
|
||||
if !settings.pending_release_migrations.contains(&key) {
|
||||
settings.pending_release_migrations.push(key);
|
||||
}
|
||||
}
|
||||
settings.clone()
|
||||
};
|
||||
|
||||
crate::settings::store::save_settings(&updated).await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn release_migration_offer(
|
||||
state: &LauncherState,
|
||||
release: &ReleaseTarget,
|
||||
) -> LauncherResult<OfferLookup> {
|
||||
let clusters = state.clusters.list().await?;
|
||||
let content = state.services.content();
|
||||
|
||||
let Some(target) = clusters
|
||||
.iter()
|
||||
.filter(|cluster| {
|
||||
cluster.mc_loader == release.loader && cluster.mc_version == release.mc_version
|
||||
})
|
||||
.min_by_key(|cluster| cluster.created_at)
|
||||
else {
|
||||
return Ok(OfferLookup::MissingCluster);
|
||||
};
|
||||
|
||||
offer_for(target, &clusters, &content).await
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn manual_migration_offer(
|
||||
state: &LauncherState,
|
||||
target_cluster_id: i64,
|
||||
source_cluster_id: i64,
|
||||
) -> LauncherResult<OfferLookup> {
|
||||
let clusters = state.clusters.list().await?;
|
||||
let content = state.services.content();
|
||||
|
||||
let (Some(target), Some(source)) = (
|
||||
clusters
|
||||
.iter()
|
||||
.find(|cluster| cluster.id == target_cluster_id),
|
||||
clusters
|
||||
.iter()
|
||||
.find(|cluster| cluster.id == source_cluster_id),
|
||||
) else {
|
||||
return Ok(OfferLookup::MissingCluster);
|
||||
};
|
||||
|
||||
if source.id == target.id
|
||||
|| !can_migrate_manually(source.mc_loader, target.mc_loader)
|
||||
|| !has_migratable_packages(source.id, &content).await?
|
||||
{
|
||||
return Ok(OfferLookup::NoSources);
|
||||
}
|
||||
|
||||
Ok(OfferLookup::Offer(Box::new(ReleaseMigrationOffer {
|
||||
release: ReleaseTarget {
|
||||
mc_version: target.mc_version.clone(),
|
||||
loader: target.mc_loader,
|
||||
},
|
||||
target: target.clone(),
|
||||
sources: vec![source.clone()],
|
||||
})))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn rank_migration_sources(target: &Cluster, clusters: &[Cluster]) -> Vec<Cluster> {
|
||||
let target_order = version_order(&target.mc_version);
|
||||
let mut sources: Vec<Cluster> = clusters
|
||||
.iter()
|
||||
.filter(|cluster| {
|
||||
cluster.id != target.id && can_migrate_manually(cluster.mc_loader, target.mc_loader)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
sources.sort_by_key(|cluster| {
|
||||
(
|
||||
cluster.mc_loader != target.mc_loader,
|
||||
target_order
|
||||
.zip(version_order(&cluster.mc_version))
|
||||
.map(|(target, source)| source_rank(target, source)),
|
||||
Reverse(cluster.last_played),
|
||||
)
|
||||
});
|
||||
sources
|
||||
}
|
||||
|
||||
async fn offer_for(
|
||||
target: &Cluster,
|
||||
clusters: &[Cluster],
|
||||
content: &oneclient_content::ContentCtx,
|
||||
) -> LauncherResult<OfferLookup> {
|
||||
let Some(target_order) = version_order(&target.mc_version) else {
|
||||
return Ok(OfferLookup::NoSources);
|
||||
};
|
||||
|
||||
let mut sources = Vec::new();
|
||||
for cluster in clusters {
|
||||
if cluster.id == target.id || cluster.mc_loader != target.mc_loader {
|
||||
continue;
|
||||
}
|
||||
if !version_order(&cluster.mc_version).is_some_and(|order| order != target_order) {
|
||||
continue;
|
||||
}
|
||||
if has_migratable_packages(cluster.id, content).await? {
|
||||
sources.push(cluster.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if sources.is_empty() {
|
||||
return Ok(OfferLookup::NoSources);
|
||||
}
|
||||
|
||||
sources.sort_by_key(|cluster| {
|
||||
(
|
||||
version_order(&cluster.mc_version).map(|order| source_rank(target_order, order)),
|
||||
Reverse(cluster.last_played),
|
||||
)
|
||||
});
|
||||
|
||||
Ok(OfferLookup::Offer(Box::new(ReleaseMigrationOffer {
|
||||
release: ReleaseTarget {
|
||||
mc_version: target.mc_version.clone(),
|
||||
loader: target.mc_loader,
|
||||
},
|
||||
target: target.clone(),
|
||||
sources,
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn newer_minor_orders_above_older_minor() {
|
||||
assert!(version_order("26.2") < version_order("26.3"));
|
||||
assert!(version_order("26.1.2") < version_order("26.2"));
|
||||
assert!(version_order("1.21.4") < version_order("26.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrades_prefer_the_newest_older_cluster() {
|
||||
let target = version_order("26.3").unwrap();
|
||||
let mut sources = vec!["26.1", "26.4", "26.2", "1.21.4"];
|
||||
sources.sort_by_key(|v| source_rank(target, version_order(v).unwrap()));
|
||||
assert_eq!(sources, vec!["26.2", "26.1", "1.21.4", "26.4"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downgrades_prefer_the_newest_cluster() {
|
||||
let target = version_order("1.8.9").unwrap();
|
||||
let mut sources = vec!["1.21.4", "26.2", "26.1"];
|
||||
sources.sort_by_key(|v| source_rank(target, version_order(v).unwrap()));
|
||||
assert_eq!(sources, vec!["26.2", "26.1", "1.21.4"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_that_existing_clusters_move_to_is_not_a_new_release() {
|
||||
let rules = vec![RemoteMigration {
|
||||
id: "x".into(),
|
||||
from: crate::versions::MigrationSource {
|
||||
mc_version: "26.1".into(),
|
||||
loader: "fabric".into(),
|
||||
},
|
||||
to: crate::versions::MigrationTarget {
|
||||
mc_version: "26.1.2".into(),
|
||||
},
|
||||
}];
|
||||
let moved = ReleaseTarget {
|
||||
mc_version: "26.1.2".into(),
|
||||
loader: GameLoader::Fabric,
|
||||
};
|
||||
let released = ReleaseTarget {
|
||||
mc_version: "26.3".into(),
|
||||
loader: GameLoader::Fabric,
|
||||
};
|
||||
let other_loader = ReleaseTarget {
|
||||
mc_version: "26.1.2".into(),
|
||||
loader: GameLoader::NeoForge,
|
||||
};
|
||||
|
||||
assert!(is_migration_destination(&moved, &rules));
|
||||
assert!(!is_migration_destination(&released, &rules));
|
||||
assert!(!is_migration_destination(&other_loader, &rules));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_same_version_source_comes_before_older_ones() {
|
||||
let target = version_order("26.2").unwrap();
|
||||
let mut sources = vec!["26.1.2", "26.2", "1.21.11"];
|
||||
sources.sort_by_key(|v| source_rank(target, version_order(v).unwrap()));
|
||||
assert_eq!(sources, vec!["26.2", "26.1.2", "1.21.11"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fabric_sources_can_fill_an_ornithe_cluster_manually() {
|
||||
assert!(can_migrate_manually(
|
||||
GameLoader::Fabric,
|
||||
GameLoader::Ornithe
|
||||
));
|
||||
assert!(can_migrate_manually(GameLoader::Fabric, GameLoader::Fabric));
|
||||
assert!(!can_migrate_manually(
|
||||
GameLoader::Ornithe,
|
||||
GameLoader::Fabric
|
||||
));
|
||||
assert!(!can_migrate_manually(
|
||||
GameLoader::Forge,
|
||||
GameLoader::Ornithe
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_minor_orders_below_its_patches() {
|
||||
assert!(version_order("26.3") < version_order("26.3.1"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ pub struct LauncherSettings {
|
|||
pub declined_tos: bool,
|
||||
pub seen_versions: Vec<String>,
|
||||
pub seen_changelog_version: Option<String>,
|
||||
pub pending_release_migrations: Vec<String>,
|
||||
pub modrinth_api_key: Option<String>,
|
||||
pub curseforge_api_key: Option<String>,
|
||||
pub custom_api_endpoint: Option<String>,
|
||||
|
|
@ -119,6 +120,7 @@ impl Default for LauncherSettings {
|
|||
declined_tos: false,
|
||||
seen_versions: Vec::new(),
|
||||
seen_changelog_version: None,
|
||||
pending_release_migrations: Vec::new(),
|
||||
modrinth_api_key: None,
|
||||
curseforge_api_key: None,
|
||||
custom_api_endpoint: None,
|
||||
|
|
|
|||
|
|
@ -147,6 +147,9 @@ pub fn run_startup_tasks(state: &Arc<LauncherState>) {
|
|||
if let Err(err) = versions_res {
|
||||
tracing::error!("versions manifest sync failed: {err:#}");
|
||||
}
|
||||
if let Err(err) = crate::clusters::record_new_versions(&background).await {
|
||||
tracing::warn!("could not record new versions for migration: {err:#}");
|
||||
}
|
||||
if let Err(err) = bundles_res {
|
||||
tracing::error!("bundle catalog sync failed: {err:#}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,15 @@ use tokio::sync::RwLock;
|
|||
|
||||
use crate::LauncherResult;
|
||||
use crate::state::LauncherServices;
|
||||
use crate::versions::manifest::{RemoteMigration, VersionMetadata, VersionsManifest};
|
||||
use crate::versions::manifest::{
|
||||
ReleaseTarget, RemoteMigration, VersionMetadata, VersionsManifest, added_release_targets,
|
||||
};
|
||||
use oneclient_common::paths;
|
||||
use oneclient_net::{EtagPolicy, fetch_cached};
|
||||
|
||||
pub struct VersionsManager {
|
||||
manifest: RwLock<VersionsManifest>,
|
||||
added: std::sync::Mutex<Vec<ReleaseTarget>>,
|
||||
}
|
||||
|
||||
impl VersionsManager {
|
||||
|
|
@ -15,6 +18,7 @@ impl VersionsManager {
|
|||
pub fn new() -> Self {
|
||||
Self {
|
||||
manifest: RwLock::new(VersionsManifest::default()),
|
||||
added: std::sync::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -28,6 +32,7 @@ impl VersionsManager {
|
|||
|
||||
Self {
|
||||
manifest: RwLock::new(manifest),
|
||||
added: std::sync::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +54,20 @@ impl VersionsManager {
|
|||
tracing::debug!("skipping versions sync; no remote or cached manifest available");
|
||||
return Ok(false);
|
||||
};
|
||||
*self.manifest.write().await = manifest;
|
||||
let mut current = self.manifest.write().await;
|
||||
let added = added_release_targets(¤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<ReleaseTarget> {
|
||||
std::mem::take(&mut *self.added.lock().unwrap())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(services))]
|
||||
async fn fetch_manifest(
|
||||
services: &LauncherServices,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,14 @@ pub struct RemoteEntry {
|
|||
pub long_description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub flags: EntryFlags,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EntryFlags {
|
||||
#[serde(default)]
|
||||
pub show_initial_migration: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -63,6 +71,93 @@ pub struct MigrationTarget {
|
|||
pub mc_version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReleaseTarget {
|
||||
pub mc_version: String,
|
||||
pub loader: GameLoader,
|
||||
}
|
||||
|
||||
impl ReleaseTarget {
|
||||
#[must_use]
|
||||
pub fn key(&self) -> String {
|
||||
format!("{}:{}", self.mc_version, self.loader.modrinth_name())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_key(key: &str) -> Option<Self> {
|
||||
let (mc_version, loader) = key.rsplit_once(':')?;
|
||||
if mc_version.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
mc_version: mc_version.to_string(),
|
||||
loader: GameLoader::from_str(loader).ok()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn release_targets(manifest: &VersionsManifest) -> Vec<ReleaseTarget> {
|
||||
manifest
|
||||
.clusters
|
||||
.iter()
|
||||
.flat_map(|cluster| {
|
||||
cluster.entries.iter().filter_map(move |entry| {
|
||||
Some(ReleaseTarget {
|
||||
mc_version: format_mc_version(
|
||||
cluster.major_version,
|
||||
entry.minor_version,
|
||||
entry.patch_version,
|
||||
),
|
||||
loader: GameLoader::from_str(entry.loader.as_deref()?).ok()?,
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl VersionsManifest {
|
||||
#[must_use]
|
||||
pub fn shows_initial_migration(&self, target: &ReleaseTarget) -> bool {
|
||||
self.clusters
|
||||
.iter()
|
||||
.flat_map(|cluster| {
|
||||
cluster
|
||||
.entries
|
||||
.iter()
|
||||
.map(move |entry| (cluster.major_version, entry))
|
||||
})
|
||||
.find(|(major, entry)| {
|
||||
format_mc_version(*major, entry.minor_version, entry.patch_version)
|
||||
== target.mc_version
|
||||
&& entry
|
||||
.loader
|
||||
.as_deref()
|
||||
.and_then(|loader| GameLoader::from_str(loader).ok())
|
||||
== Some(target.loader)
|
||||
})
|
||||
.and_then(|(_, entry)| entry.flags.show_initial_migration)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn added_release_targets(
|
||||
previous: &VersionsManifest,
|
||||
next: &VersionsManifest,
|
||||
) -> Vec<ReleaseTarget> {
|
||||
if previous.clusters.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let known = release_targets(previous);
|
||||
let mut added: Vec<ReleaseTarget> = Vec::new();
|
||||
for target in release_targets(next) {
|
||||
if !known.contains(&target) && !added.contains(&target) {
|
||||
added.push(target);
|
||||
}
|
||||
}
|
||||
added
|
||||
}
|
||||
|
||||
/// Returns the input unchanged when no rule matches
|
||||
/// Multi-hop chains are
|
||||
/// followed the loop is bounded by rule count so cyclic rules can't spin forever
|
||||
|
|
@ -179,6 +274,108 @@ mod tests {
|
|||
assert!(manifest.migrations.is_empty());
|
||||
}
|
||||
|
||||
fn versions(entries: &str) -> VersionsManifest {
|
||||
serde_json::from_str(&format!(
|
||||
r#"{{"clusters":[{{"major_version":26,"entries":[{entries}]}}]}}"#
|
||||
))
|
||||
.expect("should parse")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_missing_from_the_previous_manifest_is_added() {
|
||||
let previous = versions(r#"{"minor_version":2,"loader":"fabric"}"#);
|
||||
let next = versions(
|
||||
r#"{"minor_version":2,"loader":"fabric"},{"minor_version":3,"loader":"fabric"}"#,
|
||||
);
|
||||
assert_eq!(
|
||||
added_release_targets(&previous, &next),
|
||||
vec![ReleaseTarget {
|
||||
mc_version: "26.3".into(),
|
||||
loader: GameLoader::Fabric
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_added_without_a_previous_manifest() {
|
||||
let next = versions(r#"{"minor_version":3,"loader":"fabric"}"#);
|
||||
assert!(
|
||||
added_release_targets(&VersionsManifest::default(), &next).is_empty(),
|
||||
"a fresh install has no cached manifest and must not see every version as new"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_loader_for_a_known_version_is_added() {
|
||||
let previous = versions(r#"{"minor_version":3,"loader":"fabric"}"#);
|
||||
let next = versions(
|
||||
r#"{"minor_version":3,"loader":"fabric"},{"minor_version":3,"loader":"neoforge"}"#,
|
||||
);
|
||||
assert_eq!(
|
||||
added_release_targets(&previous, &next),
|
||||
vec![ReleaseTarget {
|
||||
mc_version: "26.3".into(),
|
||||
loader: GameLoader::NeoForge
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_initial_migration_reads_the_entry_flag() {
|
||||
let manifest: VersionsManifest = serde_json::from_str(
|
||||
r#"{"clusters":[{"major_version":8,"entries":[
|
||||
{"minor_version":9,"loader":"ornithe","flags":{"show_initial_migration":false}}
|
||||
]},{"major_version":26,"entries":[
|
||||
{"minor_version":3,"loader":"fabric"},
|
||||
{"minor_version":4,"loader":"fabric","flags":{}}
|
||||
]}]}"#,
|
||||
)
|
||||
.expect("should parse");
|
||||
|
||||
let target = |mc_version: &str, loader| ReleaseTarget {
|
||||
mc_version: mc_version.into(),
|
||||
loader,
|
||||
};
|
||||
assert!(!manifest.shows_initial_migration(&target("1.8.9", GameLoader::Ornithe)));
|
||||
assert!(
|
||||
manifest.shows_initial_migration(&target("26.3", GameLoader::Fabric)),
|
||||
"no flags means show"
|
||||
);
|
||||
assert!(
|
||||
manifest.shows_initial_migration(&target("26.4", GameLoader::Fabric)),
|
||||
"an empty flags object means show"
|
||||
);
|
||||
assert!(
|
||||
manifest.shows_initial_migration(&target("1.8.9", GameLoader::Fabric)),
|
||||
"the flag belongs to its own loader"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_flags_are_ignored() {
|
||||
let manifest: VersionsManifest = serde_json::from_str(
|
||||
r#"{"clusters":[{"major_version":26,"entries":[
|
||||
{"minor_version":3,"loader":"fabric","flags":{"something_new":true}}
|
||||
]}]}"#,
|
||||
)
|
||||
.expect("an unknown flag must not break parsing");
|
||||
assert_eq!(
|
||||
manifest.clusters[0].entries[0].flags.show_initial_migration,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_target_keys_round_trip() {
|
||||
let target = ReleaseTarget {
|
||||
mc_version: "1.8.9".into(),
|
||||
loader: GameLoader::Fabric,
|
||||
};
|
||||
assert_eq!(target.key(), "1.8.9:fabric");
|
||||
assert_eq!(ReleaseTarget::from_key("1.8.9:fabric"), Some(target));
|
||||
assert_eq!(ReleaseTarget::from_key("garbage"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_without_patch_key_parses() {
|
||||
let manifest: VersionsManifest = serde_json::from_str(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE release_migration_waitlist ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
88
packages/oneclient_db/src/dao/release_migration_waitlist.rs
Normal file
88
packages/oneclient_db/src/dao/release_migration_waitlist.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::models::ReleaseMigrationWaitlistRow;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn upsert(
|
||||
pool: &SqlitePool,
|
||||
target_cluster_id: i64,
|
||||
provider: i64,
|
||||
project_id: &str,
|
||||
content_type: i64,
|
||||
source_hash: &str,
|
||||
display_name: &str,
|
||||
added_at: &str,
|
||||
expires_at: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let enabled = i64::from(enabled);
|
||||
sqlx::query!(
|
||||
r"
|
||||
INSERT INTO release_migration_waitlist (
|
||||
target_cluster_id, provider, project_id, content_type,
|
||||
source_hash, display_name, added_at, expires_at, enabled
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(target_cluster_id, provider, project_id) DO UPDATE SET
|
||||
content_type = excluded.content_type,
|
||||
source_hash = excluded.source_hash,
|
||||
display_name = excluded.display_name,
|
||||
added_at = excluded.added_at,
|
||||
expires_at = excluded.expires_at,
|
||||
enabled = excluded.enabled
|
||||
",
|
||||
target_cluster_id,
|
||||
provider,
|
||||
project_id,
|
||||
content_type,
|
||||
source_hash,
|
||||
display_name,
|
||||
added_at,
|
||||
expires_at,
|
||||
enabled
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_all(pool: &SqlitePool) -> Result<Vec<ReleaseMigrationWaitlistRow>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
ReleaseMigrationWaitlistRow,
|
||||
r"
|
||||
SELECT
|
||||
target_cluster_id, provider, project_id, content_type,
|
||||
source_hash, display_name, expires_at, enabled
|
||||
FROM release_migration_waitlist
|
||||
",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
pool: &SqlitePool,
|
||||
target_cluster_id: i64,
|
||||
provider: i64,
|
||||
project_id: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
"DELETE FROM release_migration_waitlist WHERE target_cluster_id = ? AND provider = ? AND project_id = ?",
|
||||
target_cluster_id,
|
||||
provider,
|
||||
project_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_expired(pool: &SqlitePool, now: &str) -> Result<u64, sqlx::Error> {
|
||||
let result = sqlx::query!(
|
||||
"DELETE FROM release_migration_waitlist WHERE expires_at <= ?",
|
||||
now
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue