From 43190707a92c5428d3a21b7d5aaae66619e6a591 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Fri, 29 May 2026 18:52:29 +0100 Subject: [PATCH 1/6] feat: rough release channels impl draft --- .../InstallationSettings.vue | 4 + apps/app-frontend/src/helpers/types.d.ts | 1 + apps/app-frontend/src/pages/instance/Mods.vue | 9 ++ apps/app/src/api/profile.rs | 6 ++ ...8f4ec2e248f751f98140f77bea4f9d5971ef1.json | 12 --- ...dc80e96f68e8f5fd54fa2f2e9c55a8fffdc28.json | 12 +++ ...f2cd2991b4f4b1200135be79ec61827008f0.json} | 40 ++++--- ...0b2f10b52eeb4b074a3da2b1961cb2861155.json} | 40 ++++--- ...60529120000_profile-prerelease-updates.sql | 2 + packages/app-lib/src/api/profile/create.rs | 1 + packages/app-lib/src/state/cache.rs | 101 +++++++++++++++--- .../app-lib/src/state/instances/content.rs | 10 +- .../app-lib/src/state/legacy_converter.rs | 11 +- packages/app-lib/src/state/profiles.rs | 69 +++++++----- .../src/layouts/shared/content-tab/index.ts | 1 + .../content-tab/utils/update-channels.ts | 33 ++++++ .../shared/installation-settings/layout.vue | 30 ++++++ .../providers/installation-settings.ts | 3 + 18 files changed, 292 insertions(+), 93 deletions(-) delete mode 100644 packages/app-lib/.sqlx/query-27283e20fc86c941c7d6d09259d8f4ec2e248f751f98140f77bea4f9d5971ef1.json create mode 100644 packages/app-lib/.sqlx/query-4c7d0bb4d93ab74de69a15690dadc80e96f68e8f5fd54fa2f2e9c55a8fffdc28.json rename packages/app-lib/.sqlx/{query-6a434cc55635b6e325e9e5f06d21b787af281db4402c8ff45fe6a77b5be6c929.json => query-52a6997ba6aab38e36d72cc6e860f2cd2991b4f4b1200135be79ec61827008f0.json} (81%) rename packages/app-lib/.sqlx/{query-c108849d77c7627d6a11d5be34984938c41283e6091a1301fc3ca0b355ffcfa9.json => query-cf15ce2acd08c53a7415c73daa6b0b2f10b52eeb4b074a3da2b1961cb2861155.json} (81%) create mode 100644 packages/app-lib/migrations/20260529120000_profile-prerelease-updates.sql create mode 100644 packages/ui/src/layouts/shared/content-tab/utils/update-channels.ts diff --git a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue index 4b1e5af378..50d9b2630a 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue @@ -273,6 +273,10 @@ provideInstallationSettings({ isServer: false, isApp: true, showModpackVersionActions: !isMinecraftServer.value, + showPrereleaseUpdates: computed(() => instance.value.show_prerelease_updates), + setShowPrereleaseUpdates: async (value: boolean) => { + await edit(instance.value.path, { show_prerelease_updates: value }).catch(handleError) + }, repairing, reinstalling, }) diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts index 7c5f1d25e7..052ad1071e 100644 --- a/apps/app-frontend/src/helpers/types.d.ts +++ b/apps/app-frontend/src/helpers/types.d.ts @@ -14,6 +14,7 @@ export type GameInstance = { groups: string[] linked_data?: LinkedData + show_prerelease_updates: boolean created: Date modified: Date diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue index f64101f56a..befec2e70f 100644 --- a/apps/app-frontend/src/pages/instance/Mods.vue +++ b/apps/app-frontend/src/pages/instance/Mods.vue @@ -1194,6 +1194,15 @@ watch( }, ) +watch( + () => props.instance?.show_prerelease_updates, + async (newValue, oldValue) => { + if (newValue !== oldValue) { + await initProjects('must_revalidate') + } + }, +) + onUnmounted(() => { isUnmounted = true removeBeforeEach() diff --git a/apps/app/src/api/profile.rs b/apps/app/src/api/profile.rs index d33d3a3f2b..57048e9805 100644 --- a/apps/app/src/api/profile.rs +++ b/apps/app/src/api/profile.rs @@ -385,6 +385,7 @@ pub struct EditProfile { with = "serde_with::rust::double_option" )] pub linked_data: Option>, + pub show_prerelease_updates: Option, #[serde( default, @@ -449,6 +450,11 @@ pub async fn profile_edit(path: &str, edit_profile: EditProfile) -> Result<()> { if let Some(linked_data) = edit_profile.linked_data.clone() { prof.linked_data = linked_data; } + if let Some(show_prerelease_updates) = + edit_profile.show_prerelease_updates + { + prof.show_prerelease_updates = show_prerelease_updates; + } if let Some(groups) = edit_profile.groups.clone() { prof.groups = groups; } diff --git a/packages/app-lib/.sqlx/query-27283e20fc86c941c7d6d09259d8f4ec2e248f751f98140f77bea4f9d5971ef1.json b/packages/app-lib/.sqlx/query-27283e20fc86c941c7d6d09259d8f4ec2e248f751f98140f77bea4f9d5971ef1.json deleted file mode 100644 index 9c98034023..0000000000 --- a/packages/app-lib/.sqlx/query-27283e20fc86c941c7d6d09259d8f4ec2e248f751f98140f77bea4f9d5971ef1.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n INSERT INTO profiles (\n path, install_stage, name, icon_path,\n game_version, mod_loader, mod_loader_version,\n groups,\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path, override_extra_launch_args, override_custom_env_vars,\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit,\n protocol_version, launcher_feature_version\n )\n VALUES (\n $1, $2, $3, $4,\n $5, $6, $7,\n jsonb($8),\n $9, $10, $11,\n $12, $13, $14,\n $15, $16,\n $17, jsonb($18), jsonb($19),\n $20, $21, $22, $23,\n $24, $25, $26,\n $27, $28\n )\n ON CONFLICT (path) DO UPDATE SET\n install_stage = $2,\n name = $3,\n icon_path = $4,\n\n game_version = $5,\n mod_loader = $6,\n mod_loader_version = $7,\n\n groups = jsonb($8),\n\n linked_project_id = $9,\n linked_version_id = $10,\n locked = $11,\n\n created = $12,\n modified = $13,\n last_played = $14,\n\n submitted_time_played = $15,\n recent_time_played = $16,\n\n override_java_path = $17,\n override_extra_launch_args = jsonb($18),\n override_custom_env_vars = jsonb($19),\n override_mc_memory_max = $20,\n override_mc_force_fullscreen = $21,\n override_mc_game_resolution_x = $22,\n override_mc_game_resolution_y = $23,\n\n override_hook_pre_launch = $24,\n override_hook_wrapper = $25,\n override_hook_post_exit = $26,\n\n protocol_version = $27,\n launcher_feature_version = $28\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 28 - }, - "nullable": [] - }, - "hash": "27283e20fc86c941c7d6d09259d8f4ec2e248f751f98140f77bea4f9d5971ef1" -} diff --git a/packages/app-lib/.sqlx/query-4c7d0bb4d93ab74de69a15690dadc80e96f68e8f5fd54fa2f2e9c55a8fffdc28.json b/packages/app-lib/.sqlx/query-4c7d0bb4d93ab74de69a15690dadc80e96f68e8f5fd54fa2f2e9c55a8fffdc28.json new file mode 100644 index 0000000000..2f46b3fa54 --- /dev/null +++ b/packages/app-lib/.sqlx/query-4c7d0bb4d93ab74de69a15690dadc80e96f68e8f5fd54fa2f2e9c55a8fffdc28.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO profiles (\n path, install_stage, name, icon_path,\n game_version, mod_loader, mod_loader_version,\n groups,\n linked_project_id, linked_version_id, locked, show_prerelease_updates,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path, override_extra_launch_args, override_custom_env_vars,\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit,\n protocol_version, launcher_feature_version\n )\n VALUES (\n $1, $2, $3, $4,\n $5, $6, $7,\n jsonb($8),\n $9, $10, $11, $12,\n $13, $14, $15,\n $16, $17,\n $18, jsonb($19), jsonb($20),\n $21, $22, $23, $24,\n $25, $26, $27,\n $28, $29\n )\n ON CONFLICT (path) DO UPDATE SET\n install_stage = $2,\n name = $3,\n icon_path = $4,\n\n game_version = $5,\n mod_loader = $6,\n mod_loader_version = $7,\n\n groups = jsonb($8),\n\n linked_project_id = $9,\n linked_version_id = $10,\n locked = $11,\n show_prerelease_updates = $12,\n\n created = $13,\n modified = $14,\n last_played = $15,\n\n submitted_time_played = $16,\n recent_time_played = $17,\n\n override_java_path = $18,\n override_extra_launch_args = jsonb($19),\n override_custom_env_vars = jsonb($20),\n override_mc_memory_max = $21,\n override_mc_force_fullscreen = $22,\n override_mc_game_resolution_x = $23,\n override_mc_game_resolution_y = $24,\n\n override_hook_pre_launch = $25,\n override_hook_wrapper = $26,\n override_hook_post_exit = $27,\n\n protocol_version = $28,\n launcher_feature_version = $29\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 29 + }, + "nullable": [] + }, + "hash": "4c7d0bb4d93ab74de69a15690dadc80e96f68e8f5fd54fa2f2e9c55a8fffdc28" +} diff --git a/packages/app-lib/.sqlx/query-6a434cc55635b6e325e9e5f06d21b787af281db4402c8ff45fe6a77b5be6c929.json b/packages/app-lib/.sqlx/query-52a6997ba6aab38e36d72cc6e860f2cd2991b4f4b1200135be79ec61827008f0.json similarity index 81% rename from packages/app-lib/.sqlx/query-6a434cc55635b6e325e9e5f06d21b787af281db4402c8ff45fe6a77b5be6c929.json rename to packages/app-lib/.sqlx/query-52a6997ba6aab38e36d72cc6e860f2cd2991b4f4b1200135be79ec61827008f0.json index 03d8aeca6b..9791da2847 100644 --- a/packages/app-lib/.sqlx/query-6a434cc55635b6e325e9e5f06d21b787af281db4402c8ff45fe6a77b5be6c929.json +++ b/packages/app-lib/.sqlx/query-52a6997ba6aab38e36d72cc6e860f2cd2991b4f4b1200135be79ec61827008f0.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n path, install_stage, launcher_feature_version, name, icon_path,\n game_version, protocol_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE path IN (SELECT value FROM json_each($1))", + "query": "\n SELECT\n path, install_stage, launcher_feature_version, name, icon_path,\n game_version, protocol_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked, show_prerelease_updates,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE 1=$1", "describe": { "columns": [ { @@ -69,78 +69,83 @@ "type_info": "Integer" }, { - "name": "created", + "name": "show_prerelease_updates", "ordinal": 13, "type_info": "Integer" }, { - "name": "modified", + "name": "created", "ordinal": 14, "type_info": "Integer" }, { - "name": "last_played", + "name": "modified", "ordinal": 15, "type_info": "Integer" }, { - "name": "submitted_time_played", + "name": "last_played", "ordinal": 16, "type_info": "Integer" }, { - "name": "recent_time_played", + "name": "submitted_time_played", "ordinal": 17, "type_info": "Integer" }, { - "name": "override_java_path", + "name": "recent_time_played", "ordinal": 18, + "type_info": "Integer" + }, + { + "name": "override_java_path", + "ordinal": 19, "type_info": "Text" }, { "name": "override_extra_launch_args!: serde_json::Value", - "ordinal": 19, + "ordinal": 20, "type_info": "Null" }, { "name": "override_custom_env_vars!: serde_json::Value", - "ordinal": 20, + "ordinal": 21, "type_info": "Null" }, { "name": "override_mc_memory_max", - "ordinal": 21, + "ordinal": 22, "type_info": "Integer" }, { "name": "override_mc_force_fullscreen", - "ordinal": 22, + "ordinal": 23, "type_info": "Integer" }, { "name": "override_mc_game_resolution_x", - "ordinal": 23, + "ordinal": 24, "type_info": "Integer" }, { "name": "override_mc_game_resolution_y", - "ordinal": 24, + "ordinal": 25, "type_info": "Integer" }, { "name": "override_hook_pre_launch", - "ordinal": 25, + "ordinal": 26, "type_info": "Text" }, { "name": "override_hook_wrapper", - "ordinal": 26, + "ordinal": 27, "type_info": "Text" }, { "name": "override_hook_post_exit", - "ordinal": 27, + "ordinal": 28, "type_info": "Text" } ], @@ -163,6 +168,7 @@ true, false, false, + false, true, false, false, @@ -178,5 +184,5 @@ true ] }, - "hash": "6a434cc55635b6e325e9e5f06d21b787af281db4402c8ff45fe6a77b5be6c929" + "hash": "52a6997ba6aab38e36d72cc6e860f2cd2991b4f4b1200135be79ec61827008f0" } diff --git a/packages/app-lib/.sqlx/query-c108849d77c7627d6a11d5be34984938c41283e6091a1301fc3ca0b355ffcfa9.json b/packages/app-lib/.sqlx/query-cf15ce2acd08c53a7415c73daa6b0b2f10b52eeb4b074a3da2b1961cb2861155.json similarity index 81% rename from packages/app-lib/.sqlx/query-c108849d77c7627d6a11d5be34984938c41283e6091a1301fc3ca0b355ffcfa9.json rename to packages/app-lib/.sqlx/query-cf15ce2acd08c53a7415c73daa6b0b2f10b52eeb4b074a3da2b1961cb2861155.json index 75b44104e7..fb5a267549 100644 --- a/packages/app-lib/.sqlx/query-c108849d77c7627d6a11d5be34984938c41283e6091a1301fc3ca0b355ffcfa9.json +++ b/packages/app-lib/.sqlx/query-cf15ce2acd08c53a7415c73daa6b0b2f10b52eeb4b074a3da2b1961cb2861155.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n path, install_stage, launcher_feature_version, name, icon_path,\n game_version, protocol_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE 1=$1", + "query": "\n SELECT\n path, install_stage, launcher_feature_version, name, icon_path,\n game_version, protocol_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked, show_prerelease_updates,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE path IN (SELECT value FROM json_each($1))", "describe": { "columns": [ { @@ -69,78 +69,83 @@ "type_info": "Integer" }, { - "name": "created", + "name": "show_prerelease_updates", "ordinal": 13, "type_info": "Integer" }, { - "name": "modified", + "name": "created", "ordinal": 14, "type_info": "Integer" }, { - "name": "last_played", + "name": "modified", "ordinal": 15, "type_info": "Integer" }, { - "name": "submitted_time_played", + "name": "last_played", "ordinal": 16, "type_info": "Integer" }, { - "name": "recent_time_played", + "name": "submitted_time_played", "ordinal": 17, "type_info": "Integer" }, { - "name": "override_java_path", + "name": "recent_time_played", "ordinal": 18, + "type_info": "Integer" + }, + { + "name": "override_java_path", + "ordinal": 19, "type_info": "Text" }, { "name": "override_extra_launch_args!: serde_json::Value", - "ordinal": 19, + "ordinal": 20, "type_info": "Null" }, { "name": "override_custom_env_vars!: serde_json::Value", - "ordinal": 20, + "ordinal": 21, "type_info": "Null" }, { "name": "override_mc_memory_max", - "ordinal": 21, + "ordinal": 22, "type_info": "Integer" }, { "name": "override_mc_force_fullscreen", - "ordinal": 22, + "ordinal": 23, "type_info": "Integer" }, { "name": "override_mc_game_resolution_x", - "ordinal": 23, + "ordinal": 24, "type_info": "Integer" }, { "name": "override_mc_game_resolution_y", - "ordinal": 24, + "ordinal": 25, "type_info": "Integer" }, { "name": "override_hook_pre_launch", - "ordinal": 25, + "ordinal": 26, "type_info": "Text" }, { "name": "override_hook_wrapper", - "ordinal": 26, + "ordinal": 27, "type_info": "Text" }, { "name": "override_hook_post_exit", - "ordinal": 27, + "ordinal": 28, "type_info": "Text" } ], @@ -163,6 +168,7 @@ true, false, false, + false, true, false, false, @@ -178,5 +184,5 @@ true ] }, - "hash": "c108849d77c7627d6a11d5be34984938c41283e6091a1301fc3ca0b355ffcfa9" + "hash": "cf15ce2acd08c53a7415c73daa6b0b2f10b52eeb4b074a3da2b1961cb2861155" } diff --git a/packages/app-lib/migrations/20260529120000_profile-prerelease-updates.sql b/packages/app-lib/migrations/20260529120000_profile-prerelease-updates.sql new file mode 100644 index 0000000000..5fedee4780 --- /dev/null +++ b/packages/app-lib/migrations/20260529120000_profile-prerelease-updates.sql @@ -0,0 +1,2 @@ +ALTER TABLE profiles +ADD COLUMN show_prerelease_updates INTEGER NOT NULL DEFAULT FALSE; diff --git a/packages/app-lib/src/api/profile/create.rs b/packages/app-lib/src/api/profile/create.rs index bec35012bc..0c1bc05636 100644 --- a/packages/app-lib/src/api/profile/create.rs +++ b/packages/app-lib/src/api/profile/create.rs @@ -83,6 +83,7 @@ pub async fn profile_create( loader_version: loader.map(|x| x.id), groups: Vec::new(), linked_data, + show_prerelease_updates: false, created: Utc::now(), modified: Utc::now(), last_played: None, diff --git a/packages/app-lib/src/state/cache.rs b/packages/app-lib/src/state/cache.rs index 9936c99626..863e8ba6ef 100644 --- a/packages/app-lib/src/state/cache.rs +++ b/packages/app-lib/src/state/cache.rs @@ -259,9 +259,43 @@ pub struct CachedFileUpdate { pub hash: String, pub game_version: String, pub loaders: Vec, + pub channel_policy: String, pub update_version_id: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileUpdateChannelPolicy { + ReleaseOnly, + All, +} + +impl FileUpdateChannelPolicy { + pub fn key(self) -> &'static str { + match self { + Self::ReleaseOnly => "release", + Self::All => "all", + } + } + + fn version_types(self) -> Option> { + match self { + Self::ReleaseOnly => Some(vec!["release"]), + Self::All => None, + } + } + + fn from_key(key: &str) -> Self { + match key { + "release" => Self::ReleaseOnly, + _ => Self::All, + } + } +} + +fn default_file_update_channel_policy() -> String { + FileUpdateChannelPolicy::All.key().to_string() +} + /// Migrates old cache entries that stored `"loader": "forge"` (singular string) /// to the current `"loaders": ["forge"]` (array) format. /// SEE: https://github.com/modrinth/code/issues/5562 @@ -278,6 +312,8 @@ impl<'de> serde::Deserialize<'de> for CachedFileUpdate { loaders: Option>, #[serde(default)] loader: Option, + #[serde(default = "default_file_update_channel_policy")] + channel_policy: String, update_version_id: String, } @@ -290,6 +326,7 @@ impl<'de> serde::Deserialize<'de> for CachedFileUpdate { hash: helper.hash, game_version: helper.game_version, loaders, + channel_policy: helper.channel_policy, update_version_id: helper.update_version_id, }) } @@ -599,9 +636,10 @@ impl CacheValue { } CacheValue::FileUpdate(hash) => { format!( - "{}-{}-{}", + "{}-{}-{}-{}", hash.hash, hash.loaders.join("+"), + hash.channel_policy, hash.game_version ) } @@ -1453,20 +1491,44 @@ impl CachedEntry { let mut vals = Vec::new(); // TODO: switch to update individual once back-end route exists - let mut filtered_keys: Vec<((String, String), Vec)> = - Vec::new(); + let mut filtered_keys: Vec<( + (String, String, String), + Vec, + )> = Vec::new(); keys.iter().for_each(|x| { let string = x.key().to_string(); - let key = string.splitn(3, '-').collect::>(); + let key = string.splitn(4, '-').collect::>(); - if key.len() == 3 { - let hash = key[0]; - let loaders_key = key[1]; - let game_version = key[2]; + let parsed_key = if key.len() == 4 + && matches!(key[2], "release" | "all") + { + Some((key[0], key[1], key[2], key[3])) + } else { + let key = string.splitn(3, '-').collect::>(); + if key.len() == 3 { + Some(( + key[0], + key[1], + FileUpdateChannelPolicy::All.key(), + key[2], + )) + } else { + None + } + }; + if let Some(( + hash, + loaders_key, + channel_policy_key, + game_version, + )) = parsed_key + { if let Some(values) = filtered_keys.iter_mut().find(|x| { - x.0.0 == loaders_key && x.0.1 == game_version + x.0.0 == loaders_key + && x.0.1 == channel_policy_key + && x.0.2 == game_version }) { values.1.push(hash.to_string()); @@ -1474,6 +1536,7 @@ impl CachedEntry { filtered_keys.push(( ( loaders_key.to_string(), + channel_policy_key.to_string(), game_version.to_string(), ), vec![hash.to_string()], @@ -1489,7 +1552,9 @@ impl CachedEntry { let variations = futures::future::try_join_all(filtered_keys.iter().map( - |((loaders_key, game_version), hashes)| { + |((loaders_key, channel_policy_key, game_version), hashes)| { + let channel_policy = + FileUpdateChannelPolicy::from_key(channel_policy_key); fetch_json::>>( Method::POST, concat!(env!("MODRINTH_API_URL"), "version_files/update_many"), @@ -1498,7 +1563,8 @@ impl CachedEntry { "algorithm": "sha1", "hashes": hashes, "loaders": loaders_key.split('+').collect::>(), - "game_versions": [game_version] + "game_versions": [game_version], + "version_types": channel_policy.version_types() })), fetch_semaphore, pool, @@ -1509,8 +1575,12 @@ impl CachedEntry { for (index, mut variation) in variations.into_iter().enumerate() { - let ((loaders_key, game_version), hashes) = - &filtered_keys[index]; + let ( + (loaders_key, channel_policy_key, game_version), + hashes, + ) = &filtered_keys[index]; + let channel_policy = + FileUpdateChannelPolicy::from_key(channel_policy_key); for hash in hashes { let versions = variation.remove(hash); @@ -1531,6 +1601,9 @@ impl CachedEntry { .split('+') .map(|x| x.to_string()) .collect(), + channel_policy: channel_policy + .key() + .to_string(), update_version_id: version_id, }) .get_entry(), @@ -1541,7 +1614,7 @@ impl CachedEntry { vals.push(( CacheValueType::FileUpdate.get_empty_entry( format!( - "{hash}-{loaders_key}-{game_version}" + "{hash}-{loaders_key}-{channel_policy_key}-{game_version}" ), ), true, diff --git a/packages/app-lib/src/state/instances/content.rs b/packages/app-lib/src/state/instances/content.rs index c4a5252e46..625e7cbc82 100644 --- a/packages/app-lib/src/state/instances/content.rs +++ b/packages/app-lib/src/state/instances/content.rs @@ -225,8 +225,12 @@ pub async fn get_linked_modpack_info( }; // Check for updates - let (has_update, update_version_id, update_version) = - check_modpack_update(&linked_data.version_id, &version, all_versions); + let (has_update, update_version_id, update_version) = check_modpack_update( + &linked_data.version_id, + &version, + all_versions, + profile.show_prerelease_updates, + ); Ok(Some(LinkedModpackInfo { project, @@ -244,6 +248,7 @@ fn check_modpack_update( installed_version_id: &str, installed_version: &Version, all_versions: Option>, + show_prerelease_updates: bool, ) -> (bool, Option, Option) { let Some(versions) = all_versions else { return (false, None, None); @@ -254,6 +259,7 @@ fn check_modpack_update( .filter(|v| { v.id != installed_version_id && v.date_published > installed_version.date_published + && (show_prerelease_updates || v.version_type == "release") }) .collect(); diff --git a/packages/app-lib/src/state/legacy_converter.rs b/packages/app-lib/src/state/legacy_converter.rs index a5b9fdd136..4cc0b191c6 100644 --- a/packages/app-lib/src/state/legacy_converter.rs +++ b/packages/app-lib/src/state/legacy_converter.rs @@ -5,9 +5,9 @@ use crate::state; use crate::state::{ CacheValue, CachedEntry, CachedFile, CachedFileHash, CachedFileUpdate, Credentials, DefaultPage, DependencyType, DeviceToken, DeviceTokenKey, - DeviceTokenPair, FileType, Hooks, LauncherFeatureVersion, LinkedData, - MemorySettings, ModrinthCredentials, Profile, ProfileInstallStage, - TeamMember, Theme, VersionFile, WindowSize, + DeviceTokenPair, FileType, FileUpdateChannelPolicy, Hooks, + LauncherFeatureVersion, LinkedData, MemorySettings, ModrinthCredentials, + Profile, ProfileInstallStage, TeamMember, Theme, VersionFile, WindowSize, }; use crate::util::fetch::{IoSemaphore, read_json}; use chrono::{DateTime, Utc}; @@ -248,6 +248,10 @@ where loaders: vec![ mod_loader.as_str().to_string(), ], + channel_policy: + FileUpdateChannelPolicy::All + .key() + .to_string(), update_version_id: update_version .id .clone(), @@ -333,6 +337,7 @@ where None }), + show_prerelease_updates: false, created: profile.metadata.date_created, modified: profile.metadata.date_modified, last_played: profile.metadata.last_played, diff --git a/packages/app-lib/src/state/profiles.rs b/packages/app-lib/src/state/profiles.rs index 830f20bde2..0fef577bfd 100644 --- a/packages/app-lib/src/state/profiles.rs +++ b/packages/app-lib/src/state/profiles.rs @@ -2,7 +2,8 @@ use super::settings::{Hooks, MemorySettings, WindowSize}; use crate::profile::get_full_path; use crate::state::server_join_log::JoinLogEntry; use crate::state::{ - CacheBehaviour, CachedEntry, CachedFile, CachedFileHash, cache_file_hash, + CacheBehaviour, CachedEntry, CachedFile, CachedFileHash, + FileUpdateChannelPolicy, cache_file_hash, }; use crate::util; use crate::util::fetch::{FetchSemaphore, IoSemaphore, write_cached_icon}; @@ -39,6 +40,7 @@ pub struct Profile { pub groups: Vec, pub linked_data: Option, + pub show_prerelease_updates: bool, pub created: DateTime, pub modified: DateTime, @@ -295,6 +297,7 @@ struct ProfileQueryResult { linked_project_id: Option, linked_version_id: Option, locked: Option, + show_prerelease_updates: i64, created: i64, modified: i64, last_played: Option, @@ -344,6 +347,7 @@ impl TryFrom for Profile { } else { None }, + show_prerelease_updates: x.show_prerelease_updates == 1, created: Utc .timestamp_opt(x.created, 0) .single() @@ -394,7 +398,7 @@ macro_rules! select_profiles_with_predicate { path, install_stage, launcher_feature_version, name, icon_path, game_version, protocol_version, mod_loader, mod_loader_version, json(groups) as "groups!: serde_json::Value", - linked_project_id, linked_version_id, locked, + linked_project_id, linked_version_id, locked, show_prerelease_updates, created, modified, last_played, submitted_time_played, recent_time_played, override_java_path, @@ -514,7 +518,7 @@ impl Profile { path, install_stage, name, icon_path, game_version, mod_loader, mod_loader_version, groups, - linked_project_id, linked_version_id, locked, + linked_project_id, linked_version_id, locked, show_prerelease_updates, created, modified, last_played, submitted_time_played, recent_time_played, override_java_path, override_extra_launch_args, override_custom_env_vars, @@ -526,13 +530,13 @@ impl Profile { $1, $2, $3, $4, $5, $6, $7, jsonb($8), - $9, $10, $11, - $12, $13, $14, - $15, $16, - $17, jsonb($18), jsonb($19), - $20, $21, $22, $23, - $24, $25, $26, - $27, $28 + $9, $10, $11, $12, + $13, $14, $15, + $16, $17, + $18, jsonb($19), jsonb($20), + $21, $22, $23, $24, + $25, $26, $27, + $28, $29 ) ON CONFLICT (path) DO UPDATE SET install_stage = $2, @@ -548,28 +552,29 @@ impl Profile { linked_project_id = $9, linked_version_id = $10, locked = $11, + show_prerelease_updates = $12, - created = $12, - modified = $13, - last_played = $14, + created = $13, + modified = $14, + last_played = $15, - submitted_time_played = $15, - recent_time_played = $16, + submitted_time_played = $16, + recent_time_played = $17, - override_java_path = $17, - override_extra_launch_args = jsonb($18), - override_custom_env_vars = jsonb($19), - override_mc_memory_max = $20, - override_mc_force_fullscreen = $21, - override_mc_game_resolution_x = $22, - override_mc_game_resolution_y = $23, + override_java_path = $18, + override_extra_launch_args = jsonb($19), + override_custom_env_vars = jsonb($20), + override_mc_memory_max = $21, + override_mc_force_fullscreen = $22, + override_mc_game_resolution_x = $23, + override_mc_game_resolution_y = $24, - override_hook_pre_launch = $24, - override_hook_wrapper = $25, - override_hook_post_exit = $26, + override_hook_pre_launch = $25, + override_hook_wrapper = $26, + override_hook_post_exit = $27, - protocol_version = $27, - launcher_feature_version = $28 + protocol_version = $28, + launcher_feature_version = $29 ", self.path, install_stage, @@ -582,6 +587,7 @@ impl Profile { linked_data_project_id, linked_data_version_id, linked_data_locked, + self.show_prerelease_updates, created, modified, last_played, @@ -1211,8 +1217,14 @@ impl Profile { } fn get_cache_key(file: &CachedFileHash, profile: &Profile) -> String { + let channel_policy = if profile.show_prerelease_updates { + FileUpdateChannelPolicy::All + } else { + FileUpdateChannelPolicy::ReleaseOnly + }; + format!( - "{}-{}-{}", + "{}-{}-{}-{}", file.hash, file.project_type .filter(|x| *x != ProjectType::Mod) @@ -1220,6 +1232,7 @@ impl Profile { || profile.loader.as_str().to_string(), |x| x.get_loaders().join("+") ), + channel_policy.key(), profile.game_version ) } diff --git a/packages/ui/src/layouts/shared/content-tab/index.ts b/packages/ui/src/layouts/shared/content-tab/index.ts index 0868bc6fc9..d697ae3e92 100644 --- a/packages/ui/src/layouts/shared/content-tab/index.ts +++ b/packages/ui/src/layouts/shared/content-tab/index.ts @@ -21,4 +21,5 @@ export { default as ContentCardLayout } from './layout.vue' export { default as ContentPageLayout } from './layout.vue' export * from './providers' export * from './types' +export * from './utils/update-channels' export { default as ConfirmLeaveModal } from '#ui/components/modal/ConfirmLeaveModal.vue' diff --git a/packages/ui/src/layouts/shared/content-tab/utils/update-channels.ts b/packages/ui/src/layouts/shared/content-tab/utils/update-channels.ts new file mode 100644 index 0000000000..9e779bd709 --- /dev/null +++ b/packages/ui/src/layouts/shared/content-tab/utils/update-channels.ts @@ -0,0 +1,33 @@ +import type { Labrinth } from '@modrinth/api-client' + +export type UpdateChannelPolicy = 'release' | 'all' + +export function allowsUpdateChannel( + version: Pick, + policy: UpdateChannelPolicy, +) { + return policy === 'all' || version.version_type === 'release' +} + +export function newestEligibleUpdate( + versions: Labrinth.Versions.v2.Version[], + currentVersionId: string, + currentPublishedAt: string | null | undefined, + policy: UpdateChannelPolicy, +) { + const currentTime = currentPublishedAt ? new Date(currentPublishedAt).getTime() : Number.NaN + + return ( + [...versions] + .sort( + (a, b) => + new Date(b.date_published).getTime() - new Date(a.date_published).getTime(), + ) + .find((version) => { + if (version.id === currentVersionId) return false + if (!allowsUpdateChannel(version, policy)) return false + if (Number.isNaN(currentTime)) return true + return new Date(version.date_published).getTime() > currentTime + }) ?? null + ) +} diff --git a/packages/ui/src/layouts/shared/installation-settings/layout.vue b/packages/ui/src/layouts/shared/installation-settings/layout.vue index fc90cef291..b8f0e7cea0 100644 --- a/packages/ui/src/layouts/shared/installation-settings/layout.vue +++ b/packages/ui/src/layouts/shared/installation-settings/layout.vue @@ -22,6 +22,7 @@ import ButtonStyled from '#ui/components/base/ButtonStyled.vue' import Chips from '#ui/components/base/Chips.vue' import Combobox from '#ui/components/base/Combobox.vue' import PaperChannelBadge from '#ui/components/base/PaperChannelBadge.vue' +import Toggle from '#ui/components/base/Toggle.vue' import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue' import { defineMessages, useVIntl } from '#ui/composables/i18n' import { commonMessages } from '#ui/utils/common-messages' @@ -290,6 +291,15 @@ const messages = defineMessages({ id: 'installation-settings.reinstalling-modpack', defaultMessage: 'Reinstalling modpack', }, + showPrereleaseUpdatesTitle: { + id: 'installation-settings.show-prerelease-updates.title', + defaultMessage: 'Show beta and alpha updates', + }, + showPrereleaseUpdatesDescription: { + id: 'installation-settings.show-prerelease-updates.description', + defaultMessage: + 'Shows prerelease project versions as available updates for this instance.', + }, unlinkButton: { id: 'installation-settings.unlink', defaultMessage: 'Unlink', @@ -346,6 +356,26 @@ const messages = defineMessages({ +
+
+

+ {{ formatMessage(messages.showPrereleaseUpdatesTitle) }} +

+

+ {{ formatMessage(messages.showPrereleaseUpdatesDescription) }} +

+
+ +
+ diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json index 3cd2d9e7fe..7a3e15828e 100644 --- a/packages/ui/src/locales/en-US/index.json +++ b/packages/ui/src/locales/en-US/index.json @@ -1565,6 +1565,12 @@ "installation-settings.search-game-version": { "defaultMessage": "Search game version..." }, + "installation-settings.show-prerelease-updates.description": { + "defaultMessage": "Shows prerelease project versions as available updates for this instance." + }, + "installation-settings.show-prerelease-updates.title": { + "defaultMessage": "Show beta and alpha updates" + }, "installation-settings.type.instance": { "defaultMessage": "instance" }, From c62b71e4f39706556eaa3daedf24e02fd112496e Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Sat, 30 May 2026 12:41:27 +0100 Subject: [PATCH 3/6] fix: invalidate content queries on channel change --- .../components/ui/instance_settings/InstallationSettings.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue index 50d9b2630a..19909ba949 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue @@ -275,7 +275,10 @@ provideInstallationSettings({ showModpackVersionActions: !isMinecraftServer.value, showPrereleaseUpdates: computed(() => instance.value.show_prerelease_updates), setShowPrereleaseUpdates: async (value: boolean) => { - await edit(instance.value.path, { show_prerelease_updates: value }).catch(handleError) + const profilePath = instance.value.path + await edit(profilePath, { show_prerelease_updates: value }) + .then(() => queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', profilePath] })) + .catch(handleError) }, repairing, reinstalling, From be3d7a57fed4a2768547689a5960a097d871bc90 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Sat, 30 May 2026 18:14:09 +0100 Subject: [PATCH 4/6] fix: change to chips --- .../ui/instance_settings/GeneralSettings.vue | 105 +++++++++++ .../InstallationSettings.vue | 7 - apps/app-frontend/src/helpers/types.d.ts | 4 +- apps/app-frontend/src/pages/instance/Mods.vue | 2 +- apps/app/src/api/profile.rs | 8 +- ...63a1388ef538e4b80680bce4b668b182b4f42.json | 12 ++ ...dc80e96f68e8f5fd54fa2f2e9c55a8fffdc28.json | 12 -- ...b7e57590dc75b721dd823d00572f428d0bc5.json} | 8 +- ...34e0a64101ead329bcc88e55e31b32578a97.json} | 8 +- ...60529120000_profile-prerelease-updates.sql | 2 +- packages/app-lib/src/api/mod.rs | 1 + packages/app-lib/src/api/profile/create.rs | 6 +- packages/app-lib/src/state/cache.rs | 138 ++++++++++---- .../app-lib/src/state/instances/content.rs | 46 +++-- .../app-lib/src/state/legacy_converter.rs | 13 +- packages/app-lib/src/state/profiles.rs | 175 ++++++++++++++---- .../content-tab/utils/update-channels.ts | 61 +++++- .../shared/installation-settings/layout.vue | 29 --- .../providers/installation-settings.ts | 3 - packages/ui/src/locales/en-US/index.json | 6 - 20 files changed, 465 insertions(+), 181 deletions(-) create mode 100644 packages/app-lib/.sqlx/query-22202dacf6b5bade90c909cb4a663a1388ef538e4b80680bce4b668b182b4f42.json delete mode 100644 packages/app-lib/.sqlx/query-4c7d0bb4d93ab74de69a15690dadc80e96f68e8f5fd54fa2f2e9c55a8fffdc28.json rename packages/app-lib/.sqlx/{query-52a6997ba6aab38e36d72cc6e860f2cd2991b4f4b1200135be79ec61827008f0.json => query-be21bfd7c36fd8c69dfffe0299eeb7e57590dc75b721dd823d00572f428d0bc5.json} (83%) rename packages/app-lib/.sqlx/{query-cf15ce2acd08c53a7415c73daa6b0b2f10b52eeb4b074a3da2b1961cb2861155.json => query-de1887a95766303d16ddcad7fe7f34e0a64101ead329bcc88e55e31b32578a97.json} (82%) diff --git a/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue index 979ca33a3f..ace17cb2e6 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue @@ -4,12 +4,14 @@ import { Avatar, ButtonStyled, Checkbox, + Chips, defineMessages, injectNotificationManager, OverflowMenu, StyledInput, useVIntl, } from '@modrinth/ui' +import { useQueryClient } from '@tanstack/vue-query' import { convertFileSrc } from '@tauri-apps/api/core' import { open } from '@tauri-apps/plugin-dialog' import { computed, type Ref, ref, watch } from 'vue' @@ -25,14 +27,22 @@ import type { GameInstance } from '../../../helpers/types' const { handleError } = injectNotificationManager() const { formatMessage } = useVIntl() const router = useRouter() +const queryClient = useQueryClient() const deleteConfirmModal = ref() const { instance } = injectInstanceSettings() +type ReleaseChannel = GameInstance['preferred_update_channel'] +const releaseChannelOptions: ReleaseChannel[] = ['release', 'beta', 'alpha'] const title = ref(instance.value.name) const icon: Ref = ref(instance.value.icon_path) const groups = ref([...instance.value.groups]) +const savingReleaseChannel = ref(false) +const selectedReleaseChannel = ref(instance.value.preferred_update_channel) +const releaseChannelDisabledItems = computed(() => + savingReleaseChannel.value ? [...releaseChannelOptions] : [], +) const newCategoryInput = ref('') @@ -51,6 +61,52 @@ const availableGroups = computed(() => [ ...new Set([...allInstances.value.flatMap((instance) => instance.groups), ...groups.value]), ]) +function formatReleaseChannelLabel(channel: ReleaseChannel) { + switch (channel) { + case 'release': + return formatMessage(messages.updateChannelRelease) + case 'beta': + return formatMessage(messages.updateChannelBeta) + case 'alpha': + return formatMessage(messages.updateChannelAlpha) + } +} + +function formatReleaseChannelDescription(channel: ReleaseChannel) { + switch (channel) { + case 'release': + return formatMessage(messages.updateChannelReleaseDescription) + case 'beta': + return formatMessage(messages.updateChannelBetaDescription) + case 'alpha': + return formatMessage(messages.updateChannelAlphaDescription) + } +} + +watch( + () => [instance.value.path, instance.value.preferred_update_channel] as const, + () => { + if (!savingReleaseChannel.value) { + selectedReleaseChannel.value = instance.value.preferred_update_channel + } + }, +) + +watch(selectedReleaseChannel, async (channel, previousChannel) => { + const previousReleaseChannel = previousChannel ?? instance.value.preferred_update_channel + if (channel === instance.value.preferred_update_channel) return + + savingReleaseChannel.value = true + const profilePath = instance.value.path + await edit(profilePath, { preferred_update_channel: channel }) + .then(() => queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', profilePath] })) + .catch((error) => { + selectedReleaseChannel.value = previousReleaseChannel + handleError(error) + }) + savingReleaseChannel.value = false +}) + async function resetIcon() { icon.value = undefined await edit_icon(instance.value.path, null).catch(handleError) @@ -175,6 +231,38 @@ const messages = defineMessages({ id: 'instance.settings.tabs.general.duplicate-button', defaultMessage: 'Duplicate', }, + updateChannel: { + id: 'instance.settings.tabs.general.update-channel', + defaultMessage: 'Update channel', + }, + updateChannelReleaseDescription: { + id: 'instance.settings.tabs.general.update-channel.release.description', + defaultMessage: 'Only stable release versions will be shown as available updates.', + }, + updateChannelBetaDescription: { + id: 'instance.settings.tabs.general.update-channel.beta.description', + defaultMessage: 'Stable release and beta versions will be shown as available updates.', + }, + updateChannelAlphaDescription: { + id: 'instance.settings.tabs.general.update-channel.alpha.description', + defaultMessage: 'Stable release, beta, and alpha versions will be shown as available updates.', + }, + updateChannelRelease: { + id: 'instance.settings.tabs.general.update-channel.release', + defaultMessage: 'Release', + }, + updateChannelBeta: { + id: 'instance.settings.tabs.general.update-channel.beta', + defaultMessage: 'Beta', + }, + updateChannelAlpha: { + id: 'instance.settings.tabs.general.update-channel.alpha', + defaultMessage: 'Alpha', + }, + selectUpdateChannelAriaLabel: { + id: 'instance.settings.tabs.general.update-channel.select', + defaultMessage: 'Select update channel', + }, deleteInstance: { id: 'instance.settings.tabs.general.delete', defaultMessage: 'Delete instance', @@ -304,6 +392,23 @@ const messages = defineMessages({

+
+

+ {{ formatMessage(messages.updateChannel) }} +

+ +

+ {{ formatReleaseChannelDescription(selectedReleaseChannel) }} +

+
+

{{ formatMessage(messages.deleteInstance) }} diff --git a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue index 19909ba949..4b1e5af378 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue @@ -273,13 +273,6 @@ provideInstallationSettings({ isServer: false, isApp: true, showModpackVersionActions: !isMinecraftServer.value, - showPrereleaseUpdates: computed(() => instance.value.show_prerelease_updates), - setShowPrereleaseUpdates: async (value: boolean) => { - const profilePath = instance.value.path - await edit(profilePath, { show_prerelease_updates: value }) - .then(() => queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', profilePath] })) - .catch(handleError) - }, repairing, reinstalling, }) diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts index 052ad1071e..143997702f 100644 --- a/apps/app-frontend/src/helpers/types.d.ts +++ b/apps/app-frontend/src/helpers/types.d.ts @@ -14,7 +14,7 @@ export type GameInstance = { groups: string[] linked_data?: LinkedData - show_prerelease_updates: boolean + preferred_update_channel: ReleaseChannel created: Date modified: Date @@ -47,6 +47,8 @@ type LinkedData = { locked: boolean } +type ReleaseChannel = 'release' | 'beta' | 'alpha' + export type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge' type ContentFile = { diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue index befec2e70f..d3a8004e07 100644 --- a/apps/app-frontend/src/pages/instance/Mods.vue +++ b/apps/app-frontend/src/pages/instance/Mods.vue @@ -1195,7 +1195,7 @@ watch( ) watch( - () => props.instance?.show_prerelease_updates, + () => props.instance?.preferred_update_channel, async (newValue, oldValue) => { if (newValue !== oldValue) { await initProjects('must_revalidate') diff --git a/apps/app/src/api/profile.rs b/apps/app/src/api/profile.rs index 57048e9805..e35a3675c1 100644 --- a/apps/app/src/api/profile.rs +++ b/apps/app/src/api/profile.rs @@ -385,7 +385,7 @@ pub struct EditProfile { with = "serde_with::rust::double_option" )] pub linked_data: Option>, - pub show_prerelease_updates: Option, + pub preferred_update_channel: Option, #[serde( default, @@ -450,10 +450,10 @@ pub async fn profile_edit(path: &str, edit_profile: EditProfile) -> Result<()> { if let Some(linked_data) = edit_profile.linked_data.clone() { prof.linked_data = linked_data; } - if let Some(show_prerelease_updates) = - edit_profile.show_prerelease_updates + if let Some(preferred_update_channel) = + edit_profile.preferred_update_channel { - prof.show_prerelease_updates = show_prerelease_updates; + prof.preferred_update_channel = preferred_update_channel; } if let Some(groups) = edit_profile.groups.clone() { prof.groups = groups; diff --git a/packages/app-lib/.sqlx/query-22202dacf6b5bade90c909cb4a663a1388ef538e4b80680bce4b668b182b4f42.json b/packages/app-lib/.sqlx/query-22202dacf6b5bade90c909cb4a663a1388ef538e4b80680bce4b668b182b4f42.json new file mode 100644 index 0000000000..acc106f94d --- /dev/null +++ b/packages/app-lib/.sqlx/query-22202dacf6b5bade90c909cb4a663a1388ef538e4b80680bce4b668b182b4f42.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO profiles (\n path, install_stage, name, icon_path,\n game_version, mod_loader, mod_loader_version,\n groups,\n linked_project_id, linked_version_id, locked, preferred_update_channel,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path, override_extra_launch_args, override_custom_env_vars,\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit,\n protocol_version, launcher_feature_version\n )\n VALUES (\n $1, $2, $3, $4,\n $5, $6, $7,\n jsonb($8),\n $9, $10, $11, $12,\n $13, $14, $15,\n $16, $17,\n $18, jsonb($19), jsonb($20),\n $21, $22, $23, $24,\n $25, $26, $27,\n $28, $29\n )\n ON CONFLICT (path) DO UPDATE SET\n install_stage = $2,\n name = $3,\n icon_path = $4,\n\n game_version = $5,\n mod_loader = $6,\n mod_loader_version = $7,\n\n groups = jsonb($8),\n\n linked_project_id = $9,\n linked_version_id = $10,\n locked = $11,\n preferred_update_channel = $12,\n\n created = $13,\n modified = $14,\n last_played = $15,\n\n submitted_time_played = $16,\n recent_time_played = $17,\n\n override_java_path = $18,\n override_extra_launch_args = jsonb($19),\n override_custom_env_vars = jsonb($20),\n override_mc_memory_max = $21,\n override_mc_force_fullscreen = $22,\n override_mc_game_resolution_x = $23,\n override_mc_game_resolution_y = $24,\n\n override_hook_pre_launch = $25,\n override_hook_wrapper = $26,\n override_hook_post_exit = $27,\n\n protocol_version = $28,\n launcher_feature_version = $29\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 29 + }, + "nullable": [] + }, + "hash": "22202dacf6b5bade90c909cb4a663a1388ef538e4b80680bce4b668b182b4f42" +} diff --git a/packages/app-lib/.sqlx/query-4c7d0bb4d93ab74de69a15690dadc80e96f68e8f5fd54fa2f2e9c55a8fffdc28.json b/packages/app-lib/.sqlx/query-4c7d0bb4d93ab74de69a15690dadc80e96f68e8f5fd54fa2f2e9c55a8fffdc28.json deleted file mode 100644 index 2f46b3fa54..0000000000 --- a/packages/app-lib/.sqlx/query-4c7d0bb4d93ab74de69a15690dadc80e96f68e8f5fd54fa2f2e9c55a8fffdc28.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n INSERT INTO profiles (\n path, install_stage, name, icon_path,\n game_version, mod_loader, mod_loader_version,\n groups,\n linked_project_id, linked_version_id, locked, show_prerelease_updates,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path, override_extra_launch_args, override_custom_env_vars,\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit,\n protocol_version, launcher_feature_version\n )\n VALUES (\n $1, $2, $3, $4,\n $5, $6, $7,\n jsonb($8),\n $9, $10, $11, $12,\n $13, $14, $15,\n $16, $17,\n $18, jsonb($19), jsonb($20),\n $21, $22, $23, $24,\n $25, $26, $27,\n $28, $29\n )\n ON CONFLICT (path) DO UPDATE SET\n install_stage = $2,\n name = $3,\n icon_path = $4,\n\n game_version = $5,\n mod_loader = $6,\n mod_loader_version = $7,\n\n groups = jsonb($8),\n\n linked_project_id = $9,\n linked_version_id = $10,\n locked = $11,\n show_prerelease_updates = $12,\n\n created = $13,\n modified = $14,\n last_played = $15,\n\n submitted_time_played = $16,\n recent_time_played = $17,\n\n override_java_path = $18,\n override_extra_launch_args = jsonb($19),\n override_custom_env_vars = jsonb($20),\n override_mc_memory_max = $21,\n override_mc_force_fullscreen = $22,\n override_mc_game_resolution_x = $23,\n override_mc_game_resolution_y = $24,\n\n override_hook_pre_launch = $25,\n override_hook_wrapper = $26,\n override_hook_post_exit = $27,\n\n protocol_version = $28,\n launcher_feature_version = $29\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 29 - }, - "nullable": [] - }, - "hash": "4c7d0bb4d93ab74de69a15690dadc80e96f68e8f5fd54fa2f2e9c55a8fffdc28" -} diff --git a/packages/app-lib/.sqlx/query-52a6997ba6aab38e36d72cc6e860f2cd2991b4f4b1200135be79ec61827008f0.json b/packages/app-lib/.sqlx/query-be21bfd7c36fd8c69dfffe0299eeb7e57590dc75b721dd823d00572f428d0bc5.json similarity index 83% rename from packages/app-lib/.sqlx/query-52a6997ba6aab38e36d72cc6e860f2cd2991b4f4b1200135be79ec61827008f0.json rename to packages/app-lib/.sqlx/query-be21bfd7c36fd8c69dfffe0299eeb7e57590dc75b721dd823d00572f428d0bc5.json index 9791da2847..6f16a301a4 100644 --- a/packages/app-lib/.sqlx/query-52a6997ba6aab38e36d72cc6e860f2cd2991b4f4b1200135be79ec61827008f0.json +++ b/packages/app-lib/.sqlx/query-be21bfd7c36fd8c69dfffe0299eeb7e57590dc75b721dd823d00572f428d0bc5.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n path, install_stage, launcher_feature_version, name, icon_path,\n game_version, protocol_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked, show_prerelease_updates,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE 1=$1", + "query": "\n SELECT\n path, install_stage, launcher_feature_version, name, icon_path,\n game_version, protocol_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked, preferred_update_channel,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE 1=$1", "describe": { "columns": [ { @@ -69,9 +69,9 @@ "type_info": "Integer" }, { - "name": "show_prerelease_updates", + "name": "preferred_update_channel", "ordinal": 13, - "type_info": "Integer" + "type_info": "Text" }, { "name": "created", @@ -184,5 +184,5 @@ true ] }, - "hash": "52a6997ba6aab38e36d72cc6e860f2cd2991b4f4b1200135be79ec61827008f0" + "hash": "be21bfd7c36fd8c69dfffe0299eeb7e57590dc75b721dd823d00572f428d0bc5" } diff --git a/packages/app-lib/.sqlx/query-cf15ce2acd08c53a7415c73daa6b0b2f10b52eeb4b074a3da2b1961cb2861155.json b/packages/app-lib/.sqlx/query-de1887a95766303d16ddcad7fe7f34e0a64101ead329bcc88e55e31b32578a97.json similarity index 82% rename from packages/app-lib/.sqlx/query-cf15ce2acd08c53a7415c73daa6b0b2f10b52eeb4b074a3da2b1961cb2861155.json rename to packages/app-lib/.sqlx/query-de1887a95766303d16ddcad7fe7f34e0a64101ead329bcc88e55e31b32578a97.json index fb5a267549..775055ea41 100644 --- a/packages/app-lib/.sqlx/query-cf15ce2acd08c53a7415c73daa6b0b2f10b52eeb4b074a3da2b1961cb2861155.json +++ b/packages/app-lib/.sqlx/query-de1887a95766303d16ddcad7fe7f34e0a64101ead329bcc88e55e31b32578a97.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n path, install_stage, launcher_feature_version, name, icon_path,\n game_version, protocol_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked, show_prerelease_updates,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE path IN (SELECT value FROM json_each($1))", + "query": "\n SELECT\n path, install_stage, launcher_feature_version, name, icon_path,\n game_version, protocol_version, mod_loader, mod_loader_version,\n json(groups) as \"groups!: serde_json::Value\",\n linked_project_id, linked_version_id, locked, preferred_update_channel,\n created, modified, last_played,\n submitted_time_played, recent_time_played,\n override_java_path,\n json(override_extra_launch_args) as \"override_extra_launch_args!: serde_json::Value\", json(override_custom_env_vars) as \"override_custom_env_vars!: serde_json::Value\",\n override_mc_memory_max, override_mc_force_fullscreen, override_mc_game_resolution_x, override_mc_game_resolution_y,\n override_hook_pre_launch, override_hook_wrapper, override_hook_post_exit\n FROM profiles\n WHERE path IN (SELECT value FROM json_each($1))", "describe": { "columns": [ { @@ -69,9 +69,9 @@ "type_info": "Integer" }, { - "name": "show_prerelease_updates", + "name": "preferred_update_channel", "ordinal": 13, - "type_info": "Integer" + "type_info": "Text" }, { "name": "created", @@ -184,5 +184,5 @@ true ] }, - "hash": "cf15ce2acd08c53a7415c73daa6b0b2f10b52eeb4b074a3da2b1961cb2861155" + "hash": "de1887a95766303d16ddcad7fe7f34e0a64101ead329bcc88e55e31b32578a97" } diff --git a/packages/app-lib/migrations/20260529120000_profile-prerelease-updates.sql b/packages/app-lib/migrations/20260529120000_profile-prerelease-updates.sql index 5fedee4780..a9bdfeac4a 100644 --- a/packages/app-lib/migrations/20260529120000_profile-prerelease-updates.sql +++ b/packages/app-lib/migrations/20260529120000_profile-prerelease-updates.sql @@ -1,2 +1,2 @@ ALTER TABLE profiles -ADD COLUMN show_prerelease_updates INTEGER NOT NULL DEFAULT FALSE; +ADD COLUMN preferred_update_channel TEXT NOT NULL DEFAULT 'release'; diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs index 5cafca7249..be5964bb05 100644 --- a/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs @@ -37,6 +37,7 @@ pub mod prelude { jre, metadata, minecraft_auth, mr_auth, pack, process, profile::{self, Profile, create}, settings, + state::ReleaseChannel, util::{ io::{IOError, canonicalize}, network::{is_network_metered, tcp_listen_any_loopback}, diff --git a/packages/app-lib/src/api/profile/create.rs b/packages/app-lib/src/api/profile/create.rs index 0c1bc05636..66cd236552 100644 --- a/packages/app-lib/src/api/profile/create.rs +++ b/packages/app-lib/src/api/profile/create.rs @@ -1,7 +1,9 @@ //! Theseus profile management interface use crate::launcher::get_loader_version_from_profile; use crate::settings::Hooks; -use crate::state::{LauncherFeatureVersion, LinkedData, ProfileInstallStage}; +use crate::state::{ + LauncherFeatureVersion, LinkedData, ProfileInstallStage, ReleaseChannel, +}; use crate::util::io::{self, canonicalize}; use crate::{ErrorKind, pack, profile}; pub use crate::{State, state::Profile}; @@ -83,7 +85,7 @@ pub async fn profile_create( loader_version: loader.map(|x| x.id), groups: Vec::new(), linked_data, - show_prerelease_updates: false, + preferred_update_channel: ReleaseChannel::Release, created: Utc::now(), modified: Utc::now(), last_played: None, diff --git a/packages/app-lib/src/state/cache.rs b/packages/app-lib/src/state/cache.rs index 863e8ba6ef..15c8c8d54a 100644 --- a/packages/app-lib/src/state/cache.rs +++ b/packages/app-lib/src/state/cache.rs @@ -263,37 +263,71 @@ pub struct CachedFileUpdate { pub update_version_id: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FileUpdateChannelPolicy { - ReleaseOnly, - All, +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ReleaseChannel { + Release, + Beta, + Alpha, } -impl FileUpdateChannelPolicy { +impl ReleaseChannel { pub fn key(self) -> &'static str { match self { - Self::ReleaseOnly => "release", - Self::All => "all", + Self::Release => "release", + Self::Beta => "beta", + Self::Alpha => "alpha", + } + } + + pub fn from_key(key: &str) -> Self { + match key { + "alpha" => Self::Alpha, + "all" => Self::Alpha, + "beta" => Self::Beta, + _ => Self::Release, + } + } + + pub fn from_version_type(version_type: &str) -> Self { + match version_type { + "alpha" => Self::Alpha, + "beta" => Self::Beta, + _ => Self::Release, + } + } + + pub fn least_stable(self, other: Self) -> Self { + if self.instability_rank() >= other.instability_rank() { + self + } else { + other } } - fn version_types(self) -> Option> { + fn instability_rank(self) -> u8 { match self { - Self::ReleaseOnly => Some(vec!["release"]), - Self::All => None, + Self::Release => 0, + Self::Beta => 1, + Self::Alpha => 2, } } - fn from_key(key: &str) -> Self { - match key { - "release" => Self::ReleaseOnly, - _ => Self::All, + pub fn version_type_fallbacks(self) -> Vec> { + match self { + Self::Release => { + vec![vec!["release"], vec!["beta"], vec!["alpha"]] + } + Self::Beta => { + vec![vec!["release", "beta"], vec!["alpha"]] + } + Self::Alpha => vec![vec!["release", "beta", "alpha"]], } } } fn default_file_update_channel_policy() -> String { - FileUpdateChannelPolicy::All.key().to_string() + ReleaseChannel::Alpha.key().to_string() } /// Migrates old cache entries that stored `"loader": "forge"` (singular string) @@ -1500,7 +1534,7 @@ impl CachedEntry { let key = string.splitn(4, '-').collect::>(); let parsed_key = if key.len() == 4 - && matches!(key[2], "release" | "all") + && matches!(key[2], "release" | "beta" | "alpha" | "all") { Some((key[0], key[1], key[2], key[3])) } else { @@ -1509,7 +1543,7 @@ impl CachedEntry { Some(( key[0], key[1], - FileUpdateChannelPolicy::All.key(), + ReleaseChannel::Alpha.key(), key[2], )) } else { @@ -1552,22 +1586,56 @@ impl CachedEntry { let variations = futures::future::try_join_all(filtered_keys.iter().map( - |((loaders_key, channel_policy_key, game_version), hashes)| { + |((loaders_key, channel_policy_key, game_version), hashes)| async move { let channel_policy = - FileUpdateChannelPolicy::from_key(channel_policy_key); - fetch_json::>>( - Method::POST, - concat!(env!("MODRINTH_API_URL"), "version_files/update_many"), - None, - Some(serde_json::json!({ - "algorithm": "sha1", - "hashes": hashes, - "loaders": loaders_key.split('+').collect::>(), - "game_versions": [game_version], - "version_types": channel_policy.version_types() - })), - fetch_semaphore, - pool, + ReleaseChannel::from_key(channel_policy_key); + let mut remaining_hashes = hashes.clone(); + let mut found_versions = HashMap::new(); + + for version_types in + channel_policy.version_type_fallbacks() + { + if remaining_hashes.is_empty() { + break; + } + + let variation = fetch_json::< + HashMap>, + >( + Method::POST, + concat!( + env!("MODRINTH_API_URL"), + "version_files/update_many" + ), + None, + Some(serde_json::json!({ + "algorithm": "sha1", + "hashes": remaining_hashes.clone(), + "loaders": loaders_key.split('+').collect::>(), + "game_versions": [game_version], + "version_types": version_types + })), + fetch_semaphore, + pool, + ) + .await?; + + for (hash, versions) in variation { + found_versions.insert(hash, versions); + } + + remaining_hashes = hashes + .iter() + .filter(|hash| { + !found_versions + .contains_key(hash.as_str()) + }) + .cloned() + .collect(); + } + + Ok::>, crate::Error>( + found_versions, ) }, )) @@ -1579,9 +1647,6 @@ impl CachedEntry { (loaders_key, channel_policy_key, game_version), hashes, ) = &filtered_keys[index]; - let channel_policy = - FileUpdateChannelPolicy::from_key(channel_policy_key); - for hash in hashes { let versions = variation.remove(hash); @@ -1601,8 +1666,7 @@ impl CachedEntry { .split('+') .map(|x| x.to_string()) .collect(), - channel_policy: channel_policy - .key() + channel_policy: channel_policy_key .to_string(), update_version_id: version_id, }) diff --git a/packages/app-lib/src/state/instances/content.rs b/packages/app-lib/src/state/instances/content.rs index 625e7cbc82..1e5d26460a 100644 --- a/packages/app-lib/src/state/instances/content.rs +++ b/packages/app-lib/src/state/instances/content.rs @@ -19,7 +19,7 @@ use crate::pack::install_from::{PackFileHash, PackFormat}; use crate::state::profiles::{Profile, ProfileFile, ProjectType}; -use crate::state::{CacheBehaviour, CachedEntry}; +use crate::state::{CacheBehaviour, CachedEntry, ReleaseChannel}; use crate::util::fetch::{ DownloadMeta, DownloadReason, FetchSemaphore, fetch_mirrors, sha1_async, }; @@ -229,7 +229,7 @@ pub async fn get_linked_modpack_info( &linked_data.version_id, &version, all_versions, - profile.show_prerelease_updates, + profile.preferred_update_channel, ); Ok(Some(LinkedModpackInfo { @@ -248,26 +248,42 @@ fn check_modpack_update( installed_version_id: &str, installed_version: &Version, all_versions: Option>, - show_prerelease_updates: bool, + preferred_update_channel: ReleaseChannel, ) -> (bool, Option, Option) { let Some(versions) = all_versions else { return (false, None, None); }; - let mut newer_versions: Vec<&Version> = versions - .iter() - .filter(|v| { - v.id != installed_version_id - && v.date_published > installed_version.date_published - && (show_prerelease_updates || v.version_type == "release") - }) - .collect(); + let installed_channel = + ReleaseChannel::from_version_type(&installed_version.version_type); + let effective_channel = + preferred_update_channel.least_stable(installed_channel); - // Sort by date_published descending (newest first) - newer_versions.sort_by_key(|b| std::cmp::Reverse(b.date_published)); + for version_types in effective_channel.version_type_fallbacks() { + if !versions + .iter() + .any(|v| version_types.contains(&v.version_type.as_str())) + { + continue; + } - if let Some(newest) = newer_versions.first() { - return (true, Some(newest.id.clone()), Some((*newest).clone())); + let mut newer_versions: Vec<&Version> = versions + .iter() + .filter(|v| { + v.id != installed_version_id + && v.date_published > installed_version.date_published + && version_types.contains(&v.version_type.as_str()) + }) + .collect(); + + // Sort by date_published descending (newest first) + newer_versions.sort_by_key(|b| std::cmp::Reverse(b.date_published)); + + if let Some(newest) = newer_versions.first() { + return (true, Some(newest.id.clone()), Some((*newest).clone())); + } + + return (false, None, None); } (false, None, None) diff --git a/packages/app-lib/src/state/legacy_converter.rs b/packages/app-lib/src/state/legacy_converter.rs index 4cc0b191c6..8f65955e65 100644 --- a/packages/app-lib/src/state/legacy_converter.rs +++ b/packages/app-lib/src/state/legacy_converter.rs @@ -5,8 +5,8 @@ use crate::state; use crate::state::{ CacheValue, CachedEntry, CachedFile, CachedFileHash, CachedFileUpdate, Credentials, DefaultPage, DependencyType, DeviceToken, DeviceTokenKey, - DeviceTokenPair, FileType, FileUpdateChannelPolicy, Hooks, - LauncherFeatureVersion, LinkedData, MemorySettings, ModrinthCredentials, + DeviceTokenPair, FileType, Hooks, LauncherFeatureVersion, LinkedData, + MemorySettings, ModrinthCredentials, ReleaseChannel, Profile, ProfileInstallStage, TeamMember, Theme, VersionFile, WindowSize, }; use crate::util::fetch::{IoSemaphore, read_json}; @@ -248,10 +248,9 @@ where loaders: vec![ mod_loader.as_str().to_string(), ], - channel_policy: - FileUpdateChannelPolicy::All - .key() - .to_string(), + channel_policy: ReleaseChannel::Alpha + .key() + .to_string(), update_version_id: update_version .id .clone(), @@ -337,7 +336,7 @@ where None }), - show_prerelease_updates: false, + preferred_update_channel: ReleaseChannel::Release, created: profile.metadata.date_created, modified: profile.metadata.date_modified, last_played: profile.metadata.last_played, diff --git a/packages/app-lib/src/state/profiles.rs b/packages/app-lib/src/state/profiles.rs index 0fef577bfd..c457d7a336 100644 --- a/packages/app-lib/src/state/profiles.rs +++ b/packages/app-lib/src/state/profiles.rs @@ -2,8 +2,8 @@ use super::settings::{Hooks, MemorySettings, WindowSize}; use crate::profile::get_full_path; use crate::state::server_join_log::JoinLogEntry; use crate::state::{ - CacheBehaviour, CachedEntry, CachedFile, CachedFileHash, - FileUpdateChannelPolicy, cache_file_hash, + CacheBehaviour, CachedEntry, CachedFile, CachedFileHash, ReleaseChannel, + cache_file_hash, }; use crate::util; use crate::util::fetch::{FetchSemaphore, IoSemaphore, write_cached_icon}; @@ -40,7 +40,7 @@ pub struct Profile { pub groups: Vec, pub linked_data: Option, - pub show_prerelease_updates: bool, + pub preferred_update_channel: ReleaseChannel, pub created: DateTime, pub modified: DateTime, @@ -297,7 +297,7 @@ struct ProfileQueryResult { linked_project_id: Option, linked_version_id: Option, locked: Option, - show_prerelease_updates: i64, + preferred_update_channel: String, created: i64, modified: i64, last_played: Option, @@ -347,7 +347,9 @@ impl TryFrom for Profile { } else { None }, - show_prerelease_updates: x.show_prerelease_updates == 1, + preferred_update_channel: ReleaseChannel::from_key( + &x.preferred_update_channel, + ), created: Utc .timestamp_opt(x.created, 0) .single() @@ -398,7 +400,7 @@ macro_rules! select_profiles_with_predicate { path, install_stage, launcher_feature_version, name, icon_path, game_version, protocol_version, mod_loader, mod_loader_version, json(groups) as "groups!: serde_json::Value", - linked_project_id, linked_version_id, locked, show_prerelease_updates, + linked_project_id, linked_version_id, locked, preferred_update_channel, created, modified, last_played, submitted_time_played, recent_time_played, override_java_path, @@ -496,6 +498,7 @@ impl Profile { let linked_data_version_id = self.linked_data.as_ref().map(|x| x.version_id.clone()); let linked_data_locked = self.linked_data.as_ref().map(|x| x.locked); + let preferred_update_channel = self.preferred_update_channel.key(); let created = self.created.timestamp(); let modified = self.modified.timestamp(); @@ -518,7 +521,7 @@ impl Profile { path, install_stage, name, icon_path, game_version, mod_loader, mod_loader_version, groups, - linked_project_id, linked_version_id, locked, show_prerelease_updates, + linked_project_id, linked_version_id, locked, preferred_update_channel, created, modified, last_played, submitted_time_played, recent_time_played, override_java_path, override_extra_launch_args, override_custom_env_vars, @@ -552,7 +555,7 @@ impl Profile { linked_project_id = $9, linked_version_id = $10, locked = $11, - show_prerelease_updates = $12, + preferred_update_channel = $12, created = $13, modified = $14, @@ -587,7 +590,7 @@ impl Profile { linked_data_project_id, linked_data_version_id, linked_data_locked, - self.show_prerelease_updates, + preferred_update_channel, created, modified, last_played, @@ -752,7 +755,13 @@ impl Profile { .filter_map(|file| { all.iter() .find(|prof| file.path.contains(&prof.path)) - .map(|profile| Self::get_cache_key(file, profile)) + .map(|profile| { + Self::get_cache_key( + file, + profile, + profile.preferred_update_channel, + ) + }) }) .collect::>(); @@ -1023,10 +1032,26 @@ impl Profile { }) .collect::>(); + let installed_channels = Self::get_installed_update_channels( + &file_info_by_hash, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await?; + let file_updates = file_hashes .iter() .filter(|x| file_info_by_hash.contains_key(&x.hash)) - .map(|x| Self::get_cache_key(x, self)) + .map(|x| { + Self::get_cache_key( + x, + self, + self.effective_update_channel( + installed_channels.get(&x.hash).copied(), + ), + ) + }) .collect::>(); let file_updates_ref = @@ -1041,35 +1066,53 @@ impl Profile { (file_hashes, file_info_by_hash, file_updates) } else { - let file_updates = file_hashes - .iter() - .map(|x| Self::get_cache_key(x, self)) - .collect::>(); - let file_hashes_ref = file_hashes.iter().map(|x| &*x.hash).collect::>(); - let file_updates_ref = - file_updates.iter().map(|x| &**x).collect::>(); - let (file_info, file_updates) = tokio::try_join!( - CachedEntry::get_file_many( - &file_hashes_ref, - cache_behaviour, - pool, - fetch_semaphore, - ), - CachedEntry::get_file_update_many( - &file_updates_ref, - cache_behaviour, - pool, - fetch_semaphore, - ) - )?; + let file_info = CachedEntry::get_file_many( + &file_hashes_ref, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await?; let file_info_by_hash: HashMap = file_info .into_iter() .map(|f| (f.hash.clone(), f)) .collect(); + let installed_channels = Self::get_installed_update_channels( + &file_info_by_hash, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await?; + + let file_updates = file_hashes + .iter() + .filter(|x| file_info_by_hash.contains_key(&x.hash)) + .map(|x| { + Self::get_cache_key( + x, + self, + self.effective_update_channel( + installed_channels.get(&x.hash).copied(), + ), + ) + }) + .collect::>(); + + let file_updates_ref = + file_updates.iter().map(|x| &**x).collect::>(); + let file_updates = CachedEntry::get_file_update_many( + &file_updates_ref, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await?; + (file_hashes, file_info_by_hash, file_updates) }; @@ -1128,6 +1171,60 @@ impl Profile { Ok(files) } + async fn get_installed_update_channels( + file_info_by_hash: &HashMap, + cache_behaviour: Option, + pool: &SqlitePool, + fetch_semaphore: &FetchSemaphore, + ) -> crate::Result> { + let version_ids = file_info_by_hash + .values() + .map(|file| file.version_id.as_str()) + .collect::>(); + + if version_ids.is_empty() { + return Ok(HashMap::new()); + } + + let version_ids_ref = + version_ids.iter().copied().collect::>(); + let versions = CachedEntry::get_version_many( + &version_ids_ref, + cache_behaviour, + pool, + fetch_semaphore, + ) + .await?; + let channels_by_version_id = versions + .into_iter() + .map(|version| { + ( + version.id, + ReleaseChannel::from_version_type(&version.version_type), + ) + }) + .collect::>(); + + Ok(file_info_by_hash + .iter() + .filter_map(|(hash, file)| { + channels_by_version_id + .get(&file.version_id) + .copied() + .map(|channel| (hash.clone(), channel)) + }) + .collect()) + } + + fn effective_update_channel( + &self, + installed_channel: Option, + ) -> ReleaseChannel { + installed_channel.map_or(self.preferred_update_channel, |channel| { + self.preferred_update_channel.least_stable(channel) + }) + } + pub async fn get_installed_project_ids( &self, pool: &SqlitePool, @@ -1216,13 +1313,11 @@ impl Profile { Ok((keys, file_hashes)) } - fn get_cache_key(file: &CachedFileHash, profile: &Profile) -> String { - let channel_policy = if profile.show_prerelease_updates { - FileUpdateChannelPolicy::All - } else { - FileUpdateChannelPolicy::ReleaseOnly - }; - + fn get_cache_key( + file: &CachedFileHash, + profile: &Profile, + channel: ReleaseChannel, + ) -> String { format!( "{}-{}-{}-{}", file.hash, @@ -1232,7 +1327,7 @@ impl Profile { || profile.loader.as_str().to_string(), |x| x.get_loaders().join("+") ), - channel_policy.key(), + channel.key(), profile.game_version ) } diff --git a/packages/ui/src/layouts/shared/content-tab/utils/update-channels.ts b/packages/ui/src/layouts/shared/content-tab/utils/update-channels.ts index cd1e5f798c..0e7a760e28 100644 --- a/packages/ui/src/layouts/shared/content-tab/utils/update-channels.ts +++ b/packages/ui/src/layouts/shared/content-tab/utils/update-channels.ts @@ -1,12 +1,46 @@ import type { Labrinth } from '@modrinth/api-client' -export type UpdateChannelPolicy = 'release' | 'all' +export type UpdateChannelPolicy = 'release' | 'beta' | 'alpha' + +const channelRank: Record = { + release: 0, + beta: 1, + alpha: 2, +} + +function normalizeChannel(versionType: string): UpdateChannelPolicy { + if (versionType === 'alpha' || versionType === 'beta') return versionType + return 'release' +} + +function effectiveUpdateChannel( + policy: UpdateChannelPolicy, + currentVersionType?: string | null, +): UpdateChannelPolicy { + if (!currentVersionType) return policy + + const currentChannel = normalizeChannel(currentVersionType) + return channelRank[currentChannel] > channelRank[policy] ? currentChannel : policy +} + +function channelFallbacks(policy: UpdateChannelPolicy): UpdateChannelPolicy[][] { + switch (policy) { + case 'release': + return [['release'], ['beta'], ['alpha']] + case 'beta': + return [['release', 'beta'], ['alpha']] + case 'alpha': + return [['release', 'beta', 'alpha']] + } +} export function allowsUpdateChannel( version: Pick, policy: UpdateChannelPolicy, + currentVersionType?: string | null, ) { - return policy === 'all' || version.version_type === 'release' + const effectivePolicy = effectiveUpdateChannel(policy, currentVersionType) + return channelFallbacks(effectivePolicy)[0].includes(normalizeChannel(version.version_type)) } export function newestEligibleUpdate( @@ -14,17 +48,28 @@ export function newestEligibleUpdate( currentVersionId: string, currentPublishedAt: string | null | undefined, policy: UpdateChannelPolicy, + currentVersionType?: string | null, ) { const currentTime = currentPublishedAt ? new Date(currentPublishedAt).getTime() : Number.NaN + const sortedVersions = [...versions].sort( + (a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(), + ) + const effectivePolicy = effectiveUpdateChannel(policy, currentVersionType) - return ( - [...versions] - .sort((a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime()) - .find((version) => { + for (const versionTypes of channelFallbacks(effectivePolicy)) { + if (!versions.some((version) => versionTypes.includes(normalizeChannel(version.version_type)))) { + continue + } + + return ( + sortedVersions.find((version) => { if (version.id === currentVersionId) return false - if (!allowsUpdateChannel(version, policy)) return false + if (!versionTypes.includes(normalizeChannel(version.version_type))) return false if (Number.isNaN(currentTime)) return true return new Date(version.date_published).getTime() > currentTime }) ?? null - ) + ) + } + + return null } diff --git a/packages/ui/src/layouts/shared/installation-settings/layout.vue b/packages/ui/src/layouts/shared/installation-settings/layout.vue index c5e2808dc5..fc90cef291 100644 --- a/packages/ui/src/layouts/shared/installation-settings/layout.vue +++ b/packages/ui/src/layouts/shared/installation-settings/layout.vue @@ -22,7 +22,6 @@ import ButtonStyled from '#ui/components/base/ButtonStyled.vue' import Chips from '#ui/components/base/Chips.vue' import Combobox from '#ui/components/base/Combobox.vue' import PaperChannelBadge from '#ui/components/base/PaperChannelBadge.vue' -import Toggle from '#ui/components/base/Toggle.vue' import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue' import { defineMessages, useVIntl } from '#ui/composables/i18n' import { commonMessages } from '#ui/utils/common-messages' @@ -291,14 +290,6 @@ const messages = defineMessages({ id: 'installation-settings.reinstalling-modpack', defaultMessage: 'Reinstalling modpack', }, - showPrereleaseUpdatesTitle: { - id: 'installation-settings.show-prerelease-updates.title', - defaultMessage: 'Show beta and alpha updates', - }, - showPrereleaseUpdatesDescription: { - id: 'installation-settings.show-prerelease-updates.description', - defaultMessage: 'Shows prerelease project versions as available updates for this instance.', - }, unlinkButton: { id: 'installation-settings.unlink', defaultMessage: 'Unlink', @@ -772,26 +763,6 @@ const messages = defineMessages({ - -
-
-

- {{ formatMessage(messages.showPrereleaseUpdatesTitle) }} -

-

- {{ formatMessage(messages.showPrereleaseUpdatesDescription) }} -

-
- -

diff --git a/packages/ui/src/layouts/shared/installation-settings/providers/installation-settings.ts b/packages/ui/src/layouts/shared/installation-settings/providers/installation-settings.ts index 1aaa1f3741..0b543d6f66 100644 --- a/packages/ui/src/layouts/shared/installation-settings/providers/installation-settings.ts +++ b/packages/ui/src/layouts/shared/installation-settings/providers/installation-settings.ts @@ -57,9 +57,6 @@ export interface InstallationSettingsContext { /** When false, hides change-version and reinstall buttons in linked state (default: true) */ showModpackVersionActions?: boolean | ComputedRef - showPrereleaseUpdates?: Ref | ComputedRef - setShowPrereleaseUpdates?: (value: boolean) => Promise - /** True when the linked modpack was uploaded as a local file rather than from Modrinth */ isLocalFile?: boolean | ComputedRef diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json index 0c8f515cf7..c7520ca2be 100644 --- a/packages/ui/src/locales/en-US/index.json +++ b/packages/ui/src/locales/en-US/index.json @@ -1565,12 +1565,6 @@ "installation-settings.search-game-version": { "defaultMessage": "Search game version..." }, - "installation-settings.show-prerelease-updates.description": { - "defaultMessage": "Shows prerelease project versions as available updates for this instance." - }, - "installation-settings.show-prerelease-updates.title": { - "defaultMessage": "Show beta and alpha updates" - }, "installation-settings.type.instance": { "defaultMessage": "instance" }, From cf8c1e3528b29a3387b9f24bda40d6dfd52e91f2 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Sat, 30 May 2026 19:08:35 +0100 Subject: [PATCH 5/6] fix: lint --- .../app-frontend/src/locales/en-US/index.json | 24 +++++++++++++++++++ packages/app-lib/src/state/cache.rs | 6 +++-- .../app-lib/src/state/legacy_converter.rs | 4 ++-- packages/app-lib/src/state/profiles.rs | 11 ++++----- .../content-tab/utils/update-channels.ts | 4 +++- 5 files changed, 38 insertions(+), 11 deletions(-) diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 8b04360fe8..fd23286a14 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -695,6 +695,30 @@ "instance.settings.tabs.general.name": { "message": "Name" }, + "instance.settings.tabs.general.update-channel": { + "message": "Update channel" + }, + "instance.settings.tabs.general.update-channel.alpha": { + "message": "Alpha" + }, + "instance.settings.tabs.general.update-channel.alpha.description": { + "message": "Stable release, beta, and alpha versions will be shown as available updates." + }, + "instance.settings.tabs.general.update-channel.beta": { + "message": "Beta" + }, + "instance.settings.tabs.general.update-channel.beta.description": { + "message": "Stable release and beta versions will be shown as available updates." + }, + "instance.settings.tabs.general.update-channel.release": { + "message": "Release" + }, + "instance.settings.tabs.general.update-channel.release.description": { + "message": "Only stable release versions will be shown as available updates." + }, + "instance.settings.tabs.general.update-channel.select": { + "message": "Select update channel" + }, "instance.settings.tabs.hooks": { "message": "Launch hooks" }, diff --git a/packages/app-lib/src/state/cache.rs b/packages/app-lib/src/state/cache.rs index 15c8c8d54a..64a3d7b22c 100644 --- a/packages/app-lib/src/state/cache.rs +++ b/packages/app-lib/src/state/cache.rs @@ -1534,8 +1534,10 @@ impl CachedEntry { let key = string.splitn(4, '-').collect::>(); let parsed_key = if key.len() == 4 - && matches!(key[2], "release" | "beta" | "alpha" | "all") - { + && matches!( + key[2], + "release" | "beta" | "alpha" | "all" + ) { Some((key[0], key[1], key[2], key[3])) } else { let key = string.splitn(3, '-').collect::>(); diff --git a/packages/app-lib/src/state/legacy_converter.rs b/packages/app-lib/src/state/legacy_converter.rs index 8f65955e65..54ca5a1734 100644 --- a/packages/app-lib/src/state/legacy_converter.rs +++ b/packages/app-lib/src/state/legacy_converter.rs @@ -6,8 +6,8 @@ use crate::state::{ CacheValue, CachedEntry, CachedFile, CachedFileHash, CachedFileUpdate, Credentials, DefaultPage, DependencyType, DeviceToken, DeviceTokenKey, DeviceTokenPair, FileType, Hooks, LauncherFeatureVersion, LinkedData, - MemorySettings, ModrinthCredentials, ReleaseChannel, - Profile, ProfileInstallStage, TeamMember, Theme, VersionFile, WindowSize, + MemorySettings, ModrinthCredentials, Profile, ProfileInstallStage, + ReleaseChannel, TeamMember, Theme, VersionFile, WindowSize, }; use crate::util::fetch::{IoSemaphore, read_json}; use chrono::{DateTime, Utc}; diff --git a/packages/app-lib/src/state/profiles.rs b/packages/app-lib/src/state/profiles.rs index c457d7a336..a1a2166a38 100644 --- a/packages/app-lib/src/state/profiles.rs +++ b/packages/app-lib/src/state/profiles.rs @@ -753,15 +753,15 @@ impl Profile { let file_updates = file_hashes .iter() .filter_map(|file| { - all.iter() - .find(|prof| file.path.contains(&prof.path)) - .map(|profile| { + all.iter().find(|prof| file.path.contains(&prof.path)).map( + |profile| { Self::get_cache_key( file, profile, profile.preferred_update_channel, ) - }) + }, + ) }) .collect::>(); @@ -1186,8 +1186,7 @@ impl Profile { return Ok(HashMap::new()); } - let version_ids_ref = - version_ids.iter().copied().collect::>(); + let version_ids_ref = version_ids.iter().copied().collect::>(); let versions = CachedEntry::get_version_many( &version_ids_ref, cache_behaviour, diff --git a/packages/ui/src/layouts/shared/content-tab/utils/update-channels.ts b/packages/ui/src/layouts/shared/content-tab/utils/update-channels.ts index 0e7a760e28..088f65b276 100644 --- a/packages/ui/src/layouts/shared/content-tab/utils/update-channels.ts +++ b/packages/ui/src/layouts/shared/content-tab/utils/update-channels.ts @@ -57,7 +57,9 @@ export function newestEligibleUpdate( const effectivePolicy = effectiveUpdateChannel(policy, currentVersionType) for (const versionTypes of channelFallbacks(effectivePolicy)) { - if (!versions.some((version) => versionTypes.includes(normalizeChannel(version.version_type)))) { + if ( + !versions.some((version) => versionTypes.includes(normalizeChannel(version.version_type))) + ) { continue } From fdd991f70432dcfbd656b63f31b8dfa93fc4f4d1 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 1 Jun 2026 12:58:04 +0100 Subject: [PATCH 6/6] fix: copy --- .../src/components/ui/instance_settings/GeneralSettings.vue | 6 +++--- apps/app-frontend/src/locales/en-US/index.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue index ace17cb2e6..e30bead729 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue @@ -237,15 +237,15 @@ const messages = defineMessages({ }, updateChannelReleaseDescription: { id: 'instance.settings.tabs.general.update-channel.release.description', - defaultMessage: 'Only stable release versions will be shown as available updates.', + defaultMessage: 'Only release versions will be shown as available updates.', }, updateChannelBetaDescription: { id: 'instance.settings.tabs.general.update-channel.beta.description', - defaultMessage: 'Stable release and beta versions will be shown as available updates.', + defaultMessage: 'Release and beta versions will be shown as available updates.', }, updateChannelAlphaDescription: { id: 'instance.settings.tabs.general.update-channel.alpha.description', - defaultMessage: 'Stable release, beta, and alpha versions will be shown as available updates.', + defaultMessage: 'Release, beta, and alpha versions will be shown as available updates.', }, updateChannelRelease: { id: 'instance.settings.tabs.general.update-channel.release', diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index b27a2d5355..f0345af27b 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -711,19 +711,19 @@ "message": "Alpha" }, "instance.settings.tabs.general.update-channel.alpha.description": { - "message": "Stable release, beta, and alpha versions will be shown as available updates." + "message": "Release, beta, and alpha versions will be shown as available updates." }, "instance.settings.tabs.general.update-channel.beta": { "message": "Beta" }, "instance.settings.tabs.general.update-channel.beta.description": { - "message": "Stable release and beta versions will be shown as available updates." + "message": "Release and beta versions will be shown as available updates." }, "instance.settings.tabs.general.update-channel.release": { "message": "Release" }, "instance.settings.tabs.general.update-channel.release.description": { - "message": "Only stable release versions will be shown as available updates." + "message": "Only release versions will be shown as available updates." }, "instance.settings.tabs.general.update-channel.select": { "message": "Select update channel"