feat: release channels instance setting (#6252)

* feat: rough release channels impl draft

* feat: move to bottom + lint

* fix: invalidate content queries on channel change

* fix: change to chips

* fix: lint

* fix: copy
This commit is contained in:
Calum H.
2026-06-08 17:10:59 +00:00
committed by GitHub
parent 926c72de42
commit 9404d46782
18 changed files with 669 additions and 154 deletions
+1
View File
@@ -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},
+4 -1
View File
@@ -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,6 +85,7 @@ pub async fn profile_create(
loader_version: loader.map(|x| x.id),
groups: Vec::new(),
linked_data,
preferred_update_channel: ReleaseChannel::Release,
created: Utc::now(),
modified: Utc::now(),
last_played: None,
+165 -26
View File
@@ -259,9 +259,77 @@ pub struct CachedFileUpdate {
pub hash: String,
pub game_version: String,
pub loaders: Vec<String>,
pub channel_policy: String,
pub update_version_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReleaseChannel {
Release,
Beta,
Alpha,
}
impl ReleaseChannel {
pub fn key(self) -> &'static str {
match self {
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 instability_rank(self) -> u8 {
match self {
Self::Release => 0,
Self::Beta => 1,
Self::Alpha => 2,
}
}
pub fn version_type_fallbacks(self) -> Vec<Vec<&'static str>> {
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 {
ReleaseChannel::Alpha.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 +346,8 @@ impl<'de> serde::Deserialize<'de> for CachedFileUpdate {
loaders: Option<Vec<String>>,
#[serde(default)]
loader: Option<String>,
#[serde(default = "default_file_update_channel_policy")]
channel_policy: String,
update_version_id: String,
}
@@ -290,6 +360,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 +670,10 @@ impl CacheValue {
}
CacheValue::FileUpdate(hash) => {
format!(
"{}-{}-{}",
"{}-{}-{}-{}",
hash.hash,
hash.loaders.join("+"),
hash.channel_policy,
hash.game_version
)
}
@@ -1453,20 +1525,46 @@ 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<String>)> =
Vec::new();
let mut filtered_keys: Vec<(
(String, String, String),
Vec<String>,
)> = Vec::new();
keys.iter().for_each(|x| {
let string = x.key().to_string();
let key = string.splitn(3, '-').collect::<Vec<_>>();
let key = string.splitn(4, '-').collect::<Vec<_>>();
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" | "beta" | "alpha" | "all"
) {
Some((key[0], key[1], key[2], key[3]))
} else {
let key = string.splitn(3, '-').collect::<Vec<_>>();
if key.len() == 3 {
Some((
key[0],
key[1],
ReleaseChannel::Alpha.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 +1572,7 @@ impl CachedEntry {
filtered_keys.push((
(
loaders_key.to_string(),
channel_policy_key.to_string(),
game_version.to_string(),
),
vec![hash.to_string()],
@@ -1489,19 +1588,56 @@ impl CachedEntry {
let variations =
futures::future::try_join_all(filtered_keys.iter().map(
|((loaders_key, game_version), hashes)| {
fetch_json::<HashMap<String, Vec<Version>>>(
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::<Vec<_>>(),
"game_versions": [game_version]
})),
fetch_semaphore,
pool,
|((loaders_key, channel_policy_key, game_version), hashes)| async move {
let channel_policy =
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<String, Vec<Version>>,
>(
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::<Vec<_>>(),
"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::<HashMap<String, Vec<Version>>, crate::Error>(
found_versions,
)
},
))
@@ -1509,9 +1645,10 @@ 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];
for hash in hashes {
let versions = variation.remove(hash);
@@ -1531,6 +1668,8 @@ 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 +1680,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,
+36 -14
View File
@@ -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,
};
@@ -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.preferred_update_channel,
);
Ok(Some(LinkedModpackInfo {
project,
@@ -244,24 +248,42 @@ fn check_modpack_update(
installed_version_id: &str,
installed_version: &Version,
all_versions: Option<Vec<Version>>,
preferred_update_channel: ReleaseChannel,
) -> (bool, Option<String>, Option<Version>) {
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
})
.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)
@@ -7,7 +7,7 @@ use crate::state::{
Credentials, DefaultPage, DependencyType, DeviceToken, DeviceTokenKey,
DeviceTokenPair, FileType, Hooks, LauncherFeatureVersion, LinkedData,
MemorySettings, ModrinthCredentials, Profile, ProfileInstallStage,
TeamMember, Theme, VersionFile, WindowSize,
ReleaseChannel, TeamMember, Theme, VersionFile, WindowSize,
};
use crate::util::fetch::{IoSemaphore, read_json};
use chrono::{DateTime, Utc};
@@ -248,6 +248,9 @@ where
loaders: vec![
mod_loader.as_str().to_string(),
],
channel_policy: ReleaseChannel::Alpha
.key()
.to_string(),
update_version_id: update_version
.id
.clone(),
@@ -333,6 +336,7 @@ where
None
}),
preferred_update_channel: ReleaseChannel::Release,
created: profile.metadata.date_created,
modified: profile.metadata.date_modified,
last_played: profile.metadata.last_played,
+161 -54
View File
@@ -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, ReleaseChannel,
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<String>,
pub linked_data: Option<LinkedData>,
pub preferred_update_channel: ReleaseChannel,
pub created: DateTime<Utc>,
pub modified: DateTime<Utc>,
@@ -295,6 +297,7 @@ struct ProfileQueryResult {
linked_project_id: Option<String>,
linked_version_id: Option<String>,
locked: Option<i64>,
preferred_update_channel: String,
created: i64,
modified: i64,
last_played: Option<i64>,
@@ -344,6 +347,9 @@ impl TryFrom<ProfileQueryResult> for Profile {
} else {
None
},
preferred_update_channel: ReleaseChannel::from_key(
&x.preferred_update_channel,
),
created: Utc
.timestamp_opt(x.created, 0)
.single()
@@ -394,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,
linked_project_id, linked_version_id, locked, preferred_update_channel,
created, modified, last_played,
submitted_time_played, recent_time_played,
override_java_path,
@@ -492,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();
@@ -514,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,
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,
@@ -526,13 +533,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 +555,29 @@ impl Profile {
linked_project_id = $9,
linked_version_id = $10,
locked = $11,
preferred_update_channel = $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 +590,7 @@ impl Profile {
linked_data_project_id,
linked_data_version_id,
linked_data_locked,
preferred_update_channel,
created,
modified,
last_played,
@@ -744,9 +753,15 @@ impl Profile {
let file_updates = file_hashes
.iter()
.filter_map(|file| {
all.iter()
.find(|prof| file.path.contains(&prof.path))
.map(|profile| Self::get_cache_key(file, profile))
all.iter().find(|prof| file.path.contains(&prof.path)).map(
|profile| {
Self::get_cache_key(
file,
profile,
profile.preferred_update_channel,
)
},
)
})
.collect::<Vec<_>>();
@@ -1017,10 +1032,26 @@ impl Profile {
})
.collect::<Vec<_>>();
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::<Vec<_>>();
let file_updates_ref =
@@ -1035,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::<Vec<_>>();
let file_hashes_ref =
file_hashes.iter().map(|x| &*x.hash).collect::<Vec<_>>();
let file_updates_ref =
file_updates.iter().map(|x| &**x).collect::<Vec<_>>();
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<String, CachedFile> = 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::<Vec<_>>();
let file_updates_ref =
file_updates.iter().map(|x| &**x).collect::<Vec<_>>();
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)
};
@@ -1122,6 +1171,59 @@ impl Profile {
Ok(files)
}
async fn get_installed_update_channels(
file_info_by_hash: &HashMap<String, CachedFile>,
cache_behaviour: Option<CacheBehaviour>,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<HashMap<String, ReleaseChannel>> {
let version_ids = file_info_by_hash
.values()
.map(|file| file.version_id.as_str())
.collect::<HashSet<_>>();
if version_ids.is_empty() {
return Ok(HashMap::new());
}
let version_ids_ref = version_ids.iter().copied().collect::<Vec<_>>();
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::<HashMap<_, _>>();
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>,
) -> 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,
@@ -1210,9 +1312,13 @@ impl Profile {
Ok((keys, file_hashes))
}
fn get_cache_key(file: &CachedFileHash, profile: &Profile) -> String {
fn get_cache_key(
file: &CachedFileHash,
profile: &Profile,
channel: ReleaseChannel,
) -> String {
format!(
"{}-{}-{}",
"{}-{}-{}-{}",
file.hash,
file.project_type
.filter(|x| *x != ProjectType::Mod)
@@ -1220,6 +1326,7 @@ impl Profile {
|| profile.loader.as_str().to_string(),
|x| x.get_loaders().join("+")
),
channel.key(),
profile.game_version
)
}