This commit is contained in:
Julian Chang 2026-10-02 01:43:04 -04:00
commit 80e7cf0e59
No known key found for this signature in database
25 changed files with 1685 additions and 120 deletions

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT OR IGNORE INTO cluster_bundle_type_opt_outs (cluster_id, bundle_name, content_type)\n VALUES (?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "59a29c97c422e8721c29b9517ae834ea97776c57bb129a9d67007cdd979ae841"
}

View file

@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "\n SELECT bundle_name, content_type\n FROM cluster_bundle_type_opt_outs\n WHERE cluster_id = ?\n ",
"describe": {
"columns": [
{
"name": "bundle_name",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "cluster_bundle_type_opt_outs",
"name": "bundle_name"
}
}
},
{
"name": "content_type",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_bundle_type_opt_outs",
"name": "content_type"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "5f45ecf4e5ac27ad3a7ef4e5f9c2e6466ef51af11c332a2e6089c90759b9871f"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n DELETE FROM cluster_bundle_type_opt_outs\n WHERE cluster_id = ? AND bundle_name = ? AND content_type = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "dff3f2fe8e50eaa7ffebab7490306bc40e846ad708975991346aab83870e0968"
}

30
Cargo.lock generated
View file

@ -4934,7 +4934,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oneclient_app"
version = "2.5.1"
version = "2.6.0"
dependencies = [
"anyhow",
"arboard",
@ -4982,7 +4982,7 @@ dependencies = [
[[package]]
name = "oneclient_auth"
version = "2.5.1"
version = "2.6.0"
dependencies = [
"base64 0.23.1",
"chrono",
@ -5006,7 +5006,7 @@ dependencies = [
[[package]]
name = "oneclient_cluster"
version = "2.5.1"
version = "2.6.0"
dependencies = [
"bytes 1.12.1",
"chrono",
@ -5029,7 +5029,7 @@ dependencies = [
[[package]]
name = "oneclient_common"
version = "2.5.1"
version = "2.6.0"
dependencies = [
"directories",
"interfrost",
@ -5042,7 +5042,7 @@ dependencies = [
[[package]]
name = "oneclient_content"
version = "2.5.1"
version = "2.6.0"
dependencies = [
"async-trait",
"async_zip",
@ -5070,7 +5070,7 @@ dependencies = [
[[package]]
name = "oneclient_core"
version = "2.5.1"
version = "2.6.0"
dependencies = [
"async-compression",
"async-stream",
@ -5135,7 +5135,7 @@ dependencies = [
[[package]]
name = "oneclient_db"
version = "2.5.1"
version = "2.6.0"
dependencies = [
"async-tempfile",
"chrono",
@ -5150,7 +5150,7 @@ dependencies = [
[[package]]
name = "oneclient_discord"
version = "2.5.1"
version = "2.6.0"
dependencies = [
"chrono",
"discord-rich-presence",
@ -5162,7 +5162,7 @@ dependencies = [
[[package]]
name = "oneclient_events"
version = "2.5.1"
version = "2.6.0"
dependencies = [
"thiserror 2.0.21",
"tokio",
@ -5172,7 +5172,7 @@ dependencies = [
[[package]]
name = "oneclient_java"
version = "2.5.1"
version = "2.6.0"
dependencies = [
"astral-tokio-tar",
"async-tempfile",
@ -5195,7 +5195,7 @@ dependencies = [
[[package]]
name = "oneclient_macro"
version = "2.5.1"
version = "2.6.0"
dependencies = [
"proc-macro2",
"quote",
@ -5204,7 +5204,7 @@ dependencies = [
[[package]]
name = "oneclient_mc"
version = "2.5.1"
version = "2.6.0"
dependencies = [
"base64 0.23.1",
"chrono",
@ -5226,7 +5226,7 @@ dependencies = [
[[package]]
name = "oneclient_net"
version = "2.5.1"
version = "2.6.0"
dependencies = [
"arc-swap",
"async-trait",
@ -5248,7 +5248,7 @@ dependencies = [
[[package]]
name = "oneclient_polyplus"
version = "2.5.1"
version = "2.6.0"
dependencies = [
"async-tungstenite 0.35.0",
"bytes 1.12.1",
@ -5591,7 +5591,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]]
name = "polyio"
version = "2.5.1"
version = "2.6.0"
dependencies = [
"astral-tokio-tar",
"async-compression",

View file

@ -16,7 +16,7 @@ license = "GPL-3.0-only"
readme = "README.md"
repository = "https://github.com/Polyfrost/OneLauncher"
rust-version = "1.97"
version = "2.5.1"
version = "2.6.0"
[workspace.dependencies]
# core

View file

@ -153,10 +153,12 @@ SectionEnd
[package.metadata.packager.deb]
desktop-template = "distribution/linux/org.polyfrost.oneclient.template"
depends = ["libxdo3"]
[package.metadata.packager.appimage]
# desktop entry generated from the deb template above via the linux config
libs = ["libayatana-appindicator3.so.1"]
excluded-libraries = ["libwayland-*"]
[package.metadata.generate-rpm]
auto-req = "builtin"

View file

@ -18,6 +18,17 @@ impl Component for ConfirmLinkOverlay {
};
let open_url = url.clone();
let (title, body) = if is_first_party(&url) {
(
"Leave the launcher?",
"This will open the page in your web browser.",
)
} else {
(
"Open external link?",
"This link was provided by a third party. Only open it if you trust the source.",
)
};
OverlayPopup::new()
.overlay_level(OVERLAY_BASE_LEVEL + 3)
@ -49,7 +60,7 @@ impl Component for ConfirmLinkOverlay {
)
.child(
label()
.text("Open external link?")
.text(title)
.font_size(16.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
@ -57,7 +68,7 @@ impl Component for ConfirmLinkOverlay {
)
.child(
label()
.text("This link was provided by a third party. Only open it if you trust the source.")
.text(body)
.font_size(12.)
.color(colors::fg_secondary()),
)
@ -105,3 +116,13 @@ impl Component for ConfirmLinkOverlay {
.into_element()
}
}
fn is_first_party(url: &str) -> bool {
let host = url
.split_once("://")
.map_or(url, |(_, rest)| rest)
.split(['/', '?', '#', ':'])
.next()
.unwrap_or_default();
host == "polyfrost.org" || host.ends_with(".polyfrost.org")
}

View file

@ -98,7 +98,7 @@ pub use package_delete_dialog::use_shared_delete;
pub(crate) use package_row::{
CARD_BG, CARD_GRID_H, CARD_H, CARD_NAME, GRID_GAP, GRID_MIN_W, badge, disable_warning_body,
grid_card, icon_box, kebab_button, meta_size, meta_text, on_secondary, package_icon, pill,
remote_icon, toggle_action,
remote_icon, set_enabled_action, toggle_action,
};
pub use package_row::{
CardLayout, PackageEntry, PackageRow, github_badge, package_context_menu, provider_badge,

View file

@ -9,7 +9,8 @@ use crate::{
components::{Avatar, Icon, IconType},
hooks::{
settled_or_loading, try_default_account, use_active_cluster_id, use_browser_type,
use_clusters, use_current_account, use_dispatch, use_notifications_snapshot,
use_clusters, use_current_account, use_dispatch, use_link_confirm,
use_notifications_snapshot,
},
theme,
utils::default_cluster,
@ -185,6 +186,7 @@ impl Component for NavLink {
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focused = use_focus(a11y_id);
let mut confirm_link = use_link_confirm();
let active = self.active;
let target = self.target.clone();
@ -229,7 +231,7 @@ impl Component for NavLink {
NavTarget::Route(route) => {
let _ = RouterContext::get().push(route.clone());
}
NavTarget::External(url) => crate::platform::open_url(url),
NavTarget::External(url) => confirm_link.set(Some((*url).to_string())),
}
})
.on_pointer_over(move |_| hovering.set(true))

View file

@ -126,6 +126,7 @@ fn entry_from_item(
hash: None,
manifest_default: false,
hidden: false,
opted_in: true,
advanced: false,
update_available: false,
// Offered, not installed, so there is no recency badge to show

View file

@ -60,6 +60,8 @@ pub struct PackageEntry {
pub manifest_default: bool,
/// Private bundle dependency only set for bundle rows
pub hidden: bool,
/// False for uninstalled rows of bundles the cluster never took keeps them out of the All tab
pub opted_in: bool,
pub advanced: bool,
/// Only set for browser-installed content bundle packages use the bundle update flow
pub update_available: bool,
@ -245,22 +247,38 @@ pub(crate) fn toggle_action(
cluster_id: i64,
enabled: bool,
) -> Option<ClusterAction> {
if let Some(hash) = &item.hash {
set_enabled_action(
cluster_id,
item.hash.as_deref(),
item.bundle_name.as_deref(),
&item.package_id,
item.manifest_default,
enabled,
)
}
pub(crate) fn set_enabled_action(
cluster_id: i64,
hash: Option<&str>,
bundle_name: Option<&str>,
package_id: &str,
manifest_default: bool,
enabled: bool,
) -> Option<ClusterAction> {
if let Some(hash) = hash {
Some(ClusterAction::SetArtifactEnabled {
cluster_id,
hash: hash.clone(),
hash: hash.to_string(),
enabled,
})
} else {
item.bundle_name
.clone()
.map(|bundle_name| ClusterAction::SetBundlePackageEnabled {
cluster_id,
bundle_name,
package_id: item.package_id.clone(),
enabled,
manifest_default: item.manifest_default,
})
bundle_name.map(|bundle_name| ClusterAction::SetBundlePackageEnabled {
cluster_id,
bundle_name: bundle_name.to_string(),
package_id: package_id.to_string(),
enabled,
manifest_default,
})
}
}

View file

@ -9,8 +9,8 @@ use oneclient_core::clusters::ModpackSource;
use crate::components::{Button, Icon, IconType};
use crate::hooks::{
ALL_VERSIONS, VERSIONS_PAGE_SIZE, content_type_for_slug, use_browser_compat, use_cluster,
use_dispatch, use_installs_snapshot, use_package_versions_when, version_list,
ALL_VERSIONS, ClusterAction, VERSIONS_PAGE_SIZE, content_type_for_slug, use_browser_compat,
use_cluster, use_dispatch, use_installs_snapshot, use_package_versions_when, version_list,
};
use crate::routes::Route;
use crate::theme::colors;
@ -21,10 +21,25 @@ type InstalledMap = HashMap<(ProviderId, String), Installed>;
/// Height of the install control, matched by the installed pill that replaces it
const INSTALL_BUTTON_H: f32 = 28.;
fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option<InstallSource> {
#[derive(Clone, PartialEq)]
struct CardInstalled {
source: InstallSource,
enable: Option<ClusterAction>,
}
fn installed_for(
installed: &InstalledMap,
item: &ProjectSummary,
cluster_id: i64,
) -> Option<CardInstalled> {
installed
.get(&(item.provider, item.id.clone()))
.map(|installed| installed.source)
.map(|installed| CardInstalled {
source: installed.source,
enable: installed
.disabled_bundled()
.and_then(|version| version.enable_action(cluster_id)),
})
}
pub(super) fn grid_row(
@ -36,9 +51,9 @@ pub(super) fn grid_row(
) -> impl IntoElement {
let package_type = package_type.to_string();
let fill = cols.saturating_sub(row.len());
let installed: Vec<Option<InstallSource>> = row
let installed: Vec<Option<CardInstalled>> = row
.iter()
.map(|item| installed_for(installed, item))
.map(|item| installed_for(installed, item, cluster_id))
.collect();
rect()
@ -77,7 +92,7 @@ struct PackageCard {
item: ProjectSummary,
cluster_id: i64,
package_type: String,
installed: Option<InstallSource>,
installed: Option<CardInstalled>,
}
impl PackageCard {
@ -85,7 +100,7 @@ impl PackageCard {
item: ProjectSummary,
cluster_id: i64,
package_type: String,
installed: Option<InstallSource>,
installed: Option<CardInstalled>,
) -> Self {
Self {
item,
@ -202,7 +217,7 @@ impl Component for PackageCard {
&self.item,
self.cluster_id,
&self.package_type,
self.installed,
self.installed.clone(),
)),
)
}
@ -215,7 +230,7 @@ pub(super) fn list_row(
installed: &InstalledMap,
) -> impl IntoElement {
ListRow {
installed: installed_for(installed, &item),
installed: installed_for(installed, &item, cluster_id),
item,
cluster_id,
package_type: package_type.to_string(),
@ -227,7 +242,7 @@ struct ListRow {
item: ProjectSummary,
cluster_id: i64,
package_type: String,
installed: Option<InstallSource>,
installed: Option<CardInstalled>,
}
impl Component for ListRow {
@ -308,7 +323,7 @@ impl Component for ListRow {
&self.item,
self.cluster_id,
&self.package_type,
self.installed,
self.installed.clone(),
))
}
}
@ -320,7 +335,7 @@ struct InstallButton {
cluster_id: i64,
content_type: ContentType,
/// Cluster already has this one, so the control becomes a static pill
installed: Option<InstallSource>,
installed: Option<CardInstalled>,
}
impl InstallButton {
@ -328,7 +343,7 @@ impl InstallButton {
item: &ProjectSummary,
cluster_id: i64,
package_type: &str,
installed: Option<InstallSource>,
installed: Option<CardInstalled>,
) -> Self {
Self {
provider: item.provider,
@ -383,7 +398,17 @@ impl Component for InstallButton {
let (installing, waiting) =
use_installs_snapshot().package_busy(is_modpack, cluster_id, provider, &project_id);
if let Some(installed) = self.installed {
if let Some(installed) = &self.installed {
if let Some(action) = installed.enable.clone() {
return EnableButton {
action,
variant: EnableVariant::Card {
height: INSTALL_BUTTON_H,
},
}
.into_element();
}
let installed = installed.source;
let color = installed.color();
return rect()
.horizontal()

View file

@ -19,11 +19,12 @@ use crate::components::{
};
use crate::hooks::use_cluster;
use crate::hooks::{
BROWSE_PAGE_SIZE, BrowserUiState, bundles_with_status_items, category_list,
cluster_content_items, content_type_for_slug, pick_version_metadata, search_items,
search_pending, search_total, settled_or_loading, use_browser_compat, use_browser_state_store,
use_browser_type, use_bundles_with_status, use_cluster_content, use_clusters, use_debounced,
use_package_categories, use_package_search, use_versions, use_view_state, versions_metadata,
BROWSE_PAGE_SIZE, BrowserUiState, bundle_overrides_map, bundles_with_status_items,
category_list, cluster_content_items, content_type_for_slug, pick_version_metadata,
search_items, search_pending, search_total, settled_or_loading, use_browser_compat,
use_browser_state_store, use_browser_type, use_bundle_overrides, use_bundles_with_status,
use_cluster_content, use_clusters, use_debounced, use_package_categories, use_package_search,
use_versions, use_view_state, versions_metadata,
};
use crate::routes::Route;
use crate::theme::colors;
@ -31,8 +32,8 @@ use crate::ui::grid_columns_for_width;
use crate::view::app::cluster::supports_datapacks;
use super::{
InstallSource, Installed, ModpackVersionPrompt, PackageBanner, Thumbnail, WorldInstallPrompt,
installed_map, minecraft_choices, preferred_version,
EnableButton, EnableVariant, InstallSource, Installed, ModpackVersionPrompt, PackageBanner,
Thumbnail, WorldInstallPrompt, installed_map, minecraft_choices, preferred_version,
};
use crate::utils::{abbreviate_number, sort_clusters_for_home};
@ -250,6 +251,7 @@ impl Component for BrowserBody {
let installed = installed_map(
cluster_content_items(&use_cluster_content(cluster_id, content_type)),
&bundles_with_status_items(&use_bundles_with_status(cluster_id)),
&bundle_overrides_map(&use_bundle_overrides(cluster_id)),
);
let installed = if matches!(content_type, ContentType::DataPack | ContentType::Modpack) {
Default::default()

View file

@ -30,9 +30,12 @@ use std::collections::HashMap;
use freya::prelude::*;
use oneclient_content::packages::ProviderId;
use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo};
use oneclient_db::models::OverrideType;
use crate::components::{Icon, IconType};
use crate::hooks::{loaded_image, use_cached_image};
use crate::components::{Button, Icon, IconType, set_enabled_action};
use crate::hooks::{
ClusterAction, loaded_image, mutation_is_running, use_cached_image, use_cluster_mutation,
};
use crate::theme::colors;
use crate::ui::{ImageFallbackExt, border_all_color};
@ -61,15 +64,36 @@ impl InstallSource {
}
}
#[derive(Clone, PartialEq)]
pub(crate) struct BundlePin {
pub bundle_name: String,
pub package_id: String,
pub manifest_default: bool,
}
#[derive(Clone, PartialEq)]
pub(crate) struct InstalledVersion {
pub version_id: String,
/// The artifact to remove
/// `None` when a bundle names this version but nothing is linked
pub hash: Option<String>,
/// Only meaningful alongside a `hash` a bundle pin with nothing linked is neither on nor off
pub enabled: bool,
pub source: InstallSource,
pub bundle: Option<BundlePin>,
}
impl InstalledVersion {
pub fn enable_action(&self, cluster_id: i64) -> Option<ClusterAction> {
let pin = self.bundle.as_ref()?;
set_enabled_action(
cluster_id,
self.hash.as_deref(),
Some(&pin.bundle_name),
&pin.package_id,
pin.manifest_default,
true,
)
}
}
/// Holds every version found rather than an arbitrary winner a cluster can end up with more than one
@ -93,12 +117,20 @@ impl Installed {
pub fn is_duplicated(&self) -> bool {
self.versions.iter().filter(|v| v.hash.is_some()).count() > 1
}
pub fn disabled_bundled(&self) -> Option<&InstalledVersion> {
if self.versions.iter().any(|v| v.enabled) {
return None;
}
self.versions.iter().find(|v| v.bundle.is_some())
}
}
/// Local files and a bundle's external files are left out they have no project id to match against
pub(crate) fn installed_map(
content: Vec<LinkedArtifactInfo>,
bundles: &[BundleWithUpdateStatus],
overrides: &HashMap<(String, String), String>,
) -> HashMap<(ProviderId, String), Installed> {
let mut map: HashMap<(ProviderId, String), Installed> = HashMap::new();
@ -119,6 +151,7 @@ pub(crate) fn installed_map(
hash: Some(item.hash),
enabled: item.enabled,
source: InstallSource::Manual,
bundle: None,
});
}
}
@ -126,6 +159,7 @@ pub(crate) fn installed_map(
// Bundle membership wins a bundle's files would otherwise read as hand-installed
// The manifest pin is only a fallback for a missing linked version
for bundle in bundles {
let bundle_name = &bundle.archive.manifest.name;
for (file, _status) in &bundle.files {
if let BundleFileKind::Managed {
provider,
@ -134,18 +168,34 @@ pub(crate) fn installed_map(
..
} = &file.kind
{
let pin = BundlePin {
bundle_name: bundle_name.clone(),
package_id: file.kind.package_id(),
manifest_default: file.enabled,
};
match map.get_mut(&(*provider, project_id.clone())) {
Some(installed) => {
installed.source = InstallSource::Bundled;
if let Some(version) = installed
let only_copy = installed.versions.len() == 1;
match installed
.versions
.iter_mut()
.find(|v| &v.version_id == version_id)
{
version.source = InstallSource::Bundled;
Some(version) => {
version.source = InstallSource::Bundled;
version.bundle.get_or_insert(pin);
}
None if only_copy => {
installed.versions[0].bundle.get_or_insert(pin);
}
None => {}
}
}
None => {
let user_override = overrides
.get(&(bundle_name.clone(), pin.package_id.clone()))
.and_then(|o| OverrideType::parse(o));
map.insert(
(*provider, project_id.clone()),
Installed {
@ -153,8 +203,9 @@ pub(crate) fn installed_map(
versions: vec![InstalledVersion {
version_id: version_id.clone(),
hash: None,
enabled: false,
enabled: oneclient_core::effective_enabled(file, user_override),
source: InstallSource::Bundled,
bundle: Some(pin),
}],
},
);
@ -167,6 +218,68 @@ pub(crate) fn installed_map(
map
}
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum EnableVariant {
Sidebar,
Card { height: f32 },
VersionRow,
}
#[derive(PartialEq)]
pub(crate) struct EnableButton {
pub action: ClusterAction,
pub variant: EnableVariant,
}
impl Component for EnableButton {
fn render(&self) -> impl IntoElement {
let mutation = use_cluster_mutation();
let running = mutation_is_running(&mutation);
let action = self.action.clone();
let button = Button::new()
.enabled(!running)
.on_press(move |_| mutation.mutate(action.clone()));
match self.variant {
EnableVariant::Sidebar => button
.primary()
.width(Size::fill())
.child(Icon::new(IconType::CheckCircle).size(14.))
.text(if running { "Enabling..." } else { "Enable" })
.into_element(),
EnableVariant::Card { height } => rect()
.on_press(|e: Event<PressEventData>| e.stop_propagation())
.child(
button
.primary()
.small()
.height(Size::px(height))
.padding(Gaps::new_symmetric(0., 11.))
.child(
Icon::new(if running {
IconType::Loading02
} else {
IconType::CheckCircle
})
.size(12.)
.color(colors::fg_primary()),
)
.child(
label()
.text(if running { "Enabling" } else { "Enable" })
.font_size(11.)
.font_weight(FontWeight::SEMI_BOLD)
.max_lines(1)
.color(colors::fg_primary()),
),
)
.into_element(),
EnableVariant::VersionRow => button.secondary().small().text("Enable").into_element(),
}
}
}
pub(crate) fn installed_badge(installed: InstallSource, font_size: f32) -> impl IntoElement {
badge(installed, font_size, installed.color().with_a(38), None)
}
@ -449,9 +562,34 @@ mod tests {
.collect(),
archive: archive("performance", true, files),
has_updates: false,
opted_in_types: [ContentType::Mod].into(),
}]
}
fn installed_map(
content: Vec<LinkedArtifactInfo>,
bundles: &[BundleWithUpdateStatus],
) -> HashMap<(ProviderId, String), Installed> {
super::installed_map(content, bundles, &HashMap::new())
}
fn optional(project_id: &str, version_id: &str) -> BundleFile {
BundleFile {
enabled: false,
..managed(project_id, version_id)
}
}
fn overridden(
project_id: &str,
override_type: OverrideType,
) -> HashMap<(String, String), String> {
HashMap::from([(
("performance".to_string(), project_id.to_string()),
override_type.as_str().to_string(),
)])
}
fn entry(map: &HashMap<(ProviderId, String), Installed>, project: &str) -> Installed {
map.get(&(ProviderId::Modrinth, project.to_string()))
.expect("project missing from the map")
@ -580,4 +718,96 @@ mod tests {
);
assert_eq!(sodium.source, InstallSource::Manual);
}
#[test]
fn an_optional_mod_never_downloaded_offers_enable_through_the_bundle() {
let map = installed_map(Vec::new(), &bundles(vec![optional("sodium", "v1")]));
let sodium = entry(&map, "sodium");
let disabled = sodium.disabled_bundled().expect("offers enable");
assert!(matches!(
disabled.enable_action(7),
Some(ClusterAction::SetBundlePackageEnabled {
cluster_id: 7,
enabled: true,
manifest_default: false,
..
})
));
}
#[test]
fn an_enabled_pin_waiting_for_sync_does_not_offer_enable() {
let map = installed_map(Vec::new(), &bundles(vec![managed("sodium", "v1")]));
assert!(entry(&map, "sodium").disabled_bundled().is_none());
}
#[test]
fn a_disabled_override_on_an_unlinked_pin_offers_enable() {
let map = super::installed_map(
Vec::new(),
&bundles(vec![managed("sodium", "v1")]),
&overridden("sodium", OverrideType::Disabled),
);
let sodium = entry(&map, "sodium");
assert!(matches!(
sodium.disabled_bundled().and_then(|v| v.enable_action(7)),
Some(ClusterAction::SetBundlePackageEnabled {
manifest_default: true,
..
})
));
}
#[test]
fn a_disabled_linked_bundle_artifact_is_enabled_by_hash() {
let map = installed_map(
vec![disabled_linked("sodium", Some("v1"), "hash-1", false)],
&bundles(vec![managed("sodium", "v1")]),
);
let sodium = entry(&map, "sodium");
assert!(matches!(
sodium.disabled_bundled().and_then(|v| v.enable_action(7)),
Some(ClusterAction::SetArtifactEnabled { ref hash, enabled: true, .. }) if hash == "hash-1"
));
}
#[test]
fn a_disabled_copy_from_an_older_bundle_pin_still_offers_enable() {
let map = installed_map(
vec![disabled_linked("sodium", Some("v1"), "hash-1", false)],
&bundles(vec![managed("sodium", "v2")]),
);
assert!(entry(&map, "sodium").disabled_bundled().is_some());
}
#[test]
fn another_enabled_copy_hides_enable() {
let map = installed_map(
vec![
disabled_linked("sodium", Some("v1"), "hash-1", false),
linked("sodium", Some("v2"), "hash-2"),
],
&bundles(vec![managed("sodium", "v1")]),
);
assert!(
entry(&map, "sodium").disabled_bundled().is_none(),
"enabling the bundle copy would load two"
);
}
#[test]
fn a_hand_installed_disabled_mod_is_not_offered_enable() {
let map = installed_map(
vec![disabled_linked("sodium", Some("v1"), "hash-1", false)],
&[],
);
assert!(entry(&map, "sodium").disabled_bundled().is_none());
}
}

View file

@ -8,19 +8,20 @@ use oneclient_core::clusters::ModpackSource;
use crate::components::{ScrollArea, use_shared_delete};
use crate::hooks::use_cluster;
use crate::hooks::{
ALL_VERSIONS, VERSIONS_PAGE_SIZE, bundles_with_status_items, cluster_content_items,
content_type_for_slug, package_meta_batch, project_detail, use_browser_compat,
use_bundles_with_status, use_cluster_content, use_dispatch, use_installs_snapshot,
use_link_confirm, use_package_meta_batch, use_package_project, use_package_versions_when,
ALL_VERSIONS, VERSIONS_PAGE_SIZE, bundle_overrides_map, bundles_with_status_items,
cluster_content_items, content_type_for_slug, package_meta_batch, project_detail,
use_browser_compat, use_bundle_overrides, use_bundles_with_status, use_cluster_content,
use_dispatch, use_installs_snapshot, use_link_confirm, use_package_meta_batch,
use_package_project, use_package_versions_when,
version_list, versions_total,
};
use crate::theme::colors;
use crate::ui::border_all_color;
use super::{
Installed, InstalledVersion, ModpackVersionPrompt, PackageBanner, Thumbnail,
WorldInstallPrompt, activity_badge, installed_badge, installed_map, minecraft_choices,
preferred_version,
EnableButton, EnableVariant, Installed, InstalledVersion, ModpackVersionPrompt, PackageBanner,
Thumbnail, WorldInstallPrompt, activity_badge, installed_badge, installed_map,
minecraft_choices, preferred_version,
};
use crate::utils::abbreviate_number;
@ -220,6 +221,7 @@ impl Component for BrowserPackage {
let installed = installed_map(
cluster_content_items(&use_cluster_content(cluster_id, content_type)),
&bundles_with_status_items(&use_bundles_with_status(cluster_id)),
&bundle_overrides_map(&use_bundle_overrides(cluster_id)),
)
.remove(&(provider, project_id.clone()))
.filter(|_| !is_datapack);

View file

@ -8,7 +8,7 @@ use oneclient_content::packages::types::{
use crate::components::{
Button, Icon, IconType, Markdown, MarkdownStyle, Segment, SegmentedControl,
};
use crate::hooks::VERSIONS_PAGE_SIZE;
use crate::hooks::{ClusterAction, VERSIONS_PAGE_SIZE};
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::utils::format_size;
@ -142,6 +142,11 @@ pub(super) fn versions_panel(
let duplicated = installed
.as_ref()
.is_some_and(|installed| installed.is_duplicated());
let enable = installed
.as_ref()
.and_then(Installed::disabled_bundled)
.filter(|disabled| disabled.version_id == v.version_id)
.and_then(|disabled| disabled.enable_action(installer.cluster_id));
version_row(
v,
&dependency_names,
@ -151,6 +156,7 @@ pub(super) fn versions_panel(
tag,
duplicated,
installing,
enable,
)
.into_element()
}))
@ -226,6 +232,7 @@ fn version_row(
// Saying which version is live only tells the user anything when there are several
duplicated: bool,
installing: bool,
enable: Option<ClusterAction>,
) -> impl IntoElement {
let version_id = v.version_id.clone();
let mut chips: Vec<String> = v.loaders.iter().map(|l| l.to_string()).collect();
@ -300,9 +307,17 @@ fn version_row(
.filter(|_| duplicated)
.map(|installed| activity_badge(installed.enabled).into_element()),
)
.child(version_button(
installed, v.name, project_id, version_id, installer, on_remove, installing,
))
.child(match enable {
Some(action) => EnableButton {
action,
variant: EnableVariant::VersionRow,
}
.into_element(),
None => version_button(
installed, v.name, project_id, version_id, installer, on_remove, installing,
)
.into_element(),
})
}
/// A bundle pin with nothing linked leaves nothing to press no artifact to remove and installing by hand would duplicate it

View file

@ -30,6 +30,10 @@ pub(super) fn sidebar(
_ => false,
};
let can_install = latest_version.is_some() && !have_latest && !installing && !waiting;
let enable = installed
.as_ref()
.and_then(Installed::disabled_bundled)
.and_then(|version| version.enable_action(installer.cluster_id));
rect()
.vertical()
@ -101,8 +105,13 @@ pub(super) fn sidebar(
),
),
)
.child(
Button::new()
.child(match enable {
Some(action) => EnableButton {
action,
variant: EnableVariant::Sidebar,
}
.into_element(),
None => Button::new()
.primary()
.width(Size::fill())
.enabled(can_install)
@ -118,8 +127,9 @@ pub(super) fn sidebar(
"Latest installed"
} else {
"Install latest"
}),
)
})
.into_element(),
})
.maybe(
!project.members.is_empty() || !project.author.is_empty(),
|el| el.child(authors_card(&project, confirm)),

View file

@ -144,10 +144,13 @@ pub fn bundle_packages(
}
}
let mut ordered: Vec<&BundleWithUpdateStatus> = bundles.iter().collect();
ordered.sort_by_key(|b| !b.opted_in_types.contains(&content_type));
let mut rows = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for bundle in bundles {
for bundle in ordered {
let bundle_name = &bundle.archive.manifest.name;
let category = bundle.archive.manifest.category.clone();
for (file, status) in &bundle.files {
@ -199,6 +202,8 @@ pub fn bundle_packages(
vec![category.clone()]
};
let opted_in =
installed_info.is_some() || bundle.opted_in_types.contains(&content_type);
let mut row = make_row(
pid,
Some(bundle_name.clone()),
@ -214,6 +219,7 @@ pub fn bundle_packages(
false,
// Flagged rather than dropped `HiddenFilter` filters on the row and the seen id stops the loose-content pass resurrecting it as a local file
file.hidden,
opted_in,
);
row.advanced = advanced;
rows.push(row);
@ -246,6 +252,7 @@ pub fn bundle_packages(
.unwrap_or_else(|| info.file_name.clone()),
outdated,
false,
true,
));
}
@ -267,6 +274,7 @@ fn make_row(
fallback_name: String,
update_available: bool,
hidden: bool,
opted_in: bool,
) -> PackageEntry {
let name = m
.map(|p| p.name.clone())
@ -305,6 +313,7 @@ fn make_row(
hash: installed_info.map(|i| i.hash.clone()),
update_available,
hidden,
opted_in,
advanced: false,
seen_status: installed_info.map(|i| i.seen_status).unwrap_or_default(),
}
@ -330,7 +339,7 @@ impl Tab {
pub(super) fn matches(&self, p: &PackageEntry) -> bool {
match self {
Tab::All => true,
Tab::All => p.opted_in,
Tab::Category(c) => p.categories.iter().any(|pc| pc == c),
Tab::Browser => (p.is_remote() || p.github_hosted) && !p.in_bundle(),
Tab::Local => !p.is_remote() && !p.github_hosted,
@ -687,3 +696,67 @@ impl Component for PackageManager {
.maybe_child(delete_dialog)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::view::onboarding::test_support::{archive, file};
use oneclient_core::{BundleFile, FileUpdateStatus};
fn bundle(category: &str, opted_in: bool, files: Vec<BundleFile>) -> BundleWithUpdateStatus {
BundleWithUpdateStatus {
files: files
.iter()
.cloned()
.map(|f| (f, FileUpdateStatus::NotInstalled))
.collect(),
archive: archive(category, true, files),
has_updates: false,
opted_in_types: if opted_in {
[ContentType::Mod].into()
} else {
HashSet::new()
},
}
}
#[test]
fn all_tab_skips_bundles_not_opted_into() {
let bundles = [
bundle(
"Declined",
false,
vec![
file("only-declined", true, false),
file("shared", true, false),
],
),
bundle(
"Taken",
true,
vec![file("shared", true, false), file("only-taken", true, false)],
),
];
let rows = bundle_packages(
Vec::new(),
&bundles,
&HashMap::new(),
&PackageMetaMap::new(),
&HashSet::new(),
ContentType::Mod,
);
let all: Vec<&str> = rows
.iter()
.filter(|p| Tab::All.matches(p))
.map(|p| p.package_id.as_str())
.collect();
assert_eq!(all, ["shared", "only-taken"]);
let shared = rows.iter().find(|p| p.package_id == "shared").unwrap();
assert_eq!(shared.categories, ["Taken"]);
let declined = Tab::Category("Declined".into());
assert!(
rows.iter()
.any(|p| p.package_id == "only-declined" && declined.matches(p))
);
}
}

View file

@ -613,8 +613,6 @@ pub async fn set_bundle_package_override(
/// For bundle files the cluster has not installed
/// installed ones go through [`set_artifact_enabled_to`]
/// Matching the manifest default clears the override
/// switching *on* also drops objections filed under other bundles
#[tracing::instrument(level = "debug", skip(ctx))]
pub async fn set_bundle_package_enabled(
cluster_id: i64,
@ -625,18 +623,12 @@ pub async fn set_bundle_package_enabled(
ctx: &ContentCtx,
) -> ContentResult<()> {
let override_type = match (enabled, manifest_default) {
(true, true) | (false, false) => None,
(true, false) => Some(OverrideType::Enabled),
(true, _) => Some(OverrideType::Enabled),
(false, false) => None,
(false, true) => Some(OverrideType::Disabled),
};
set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, ctx).await?;
if enabled {
clear_suppressing_overrides(cluster_id, package_id, ctx).await?;
}
Ok(())
set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, ctx).await
}
#[tracing::instrument(level = "debug", skip(overrides, ctx), fields(count = overrides.len()))]

View file

@ -1,3 +1,5 @@
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use crate::bundles::Bundle;
@ -253,4 +255,5 @@ pub struct BundleWithUpdateStatus {
pub archive: BundleArchive,
pub files: Vec<(BundleFile, FileUpdateStatus)>,
pub has_updates: bool,
pub opted_in_types: HashSet<ContentType>,
}

View file

@ -18,7 +18,7 @@ use futures_util::StreamExt;
use crate::bundles::install::{
BUNDLE_INSTALL_CONCURRENCY, bundle_cluster, disable_was_deliberate, external_ids_by_sha1,
find_user_suppression, heal_bundle_activity, install_package_from_bundle,
find_override, find_user_suppression, heal_bundle_activity, install_package_from_bundle,
remove_artifact_from_cluster, set_artifact_enabled_to,
};
use crate::bundles::manager::BundlesManager;
@ -32,7 +32,7 @@ use crate::ctx::ContentCtx;
use crate::error::{ContentError, ContentResult};
use crate::packages::store::PackageStore;
use crate::packages::types::LinkedArtifactInfo;
use oneclient_common::domain::{GameLoader, ProviderId};
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
static CLUSTER_UPDATE_LOCKS: OnceLock<Mutex<HashMap<i64, Arc<AsyncMutex<()>>>>> = OnceLock::new();
@ -322,33 +322,40 @@ async fn check_bundle_updates_inner(
let addition_eligible_bundles = addition_eligible_bundles(
ctx,
&archives,
&bundle_packages,
&all_linked,
&candidate_keys_by_bundle,
overrides,
&overrides_map,
)
.await?;
let mut additions_available = Vec::new();
let mut optional_available: Vec<(String, BundleOptionalPackage)> = Vec::new();
let mut planned_addition_keys = all_installed_managed_keys.clone();
let tracked_package_by_hash: HashMap<&str, &str> = bundle_packages
.iter()
.filter_map(|bp| Some((bp.hash.as_str(), bp.package_id.as_deref()?)))
.collect();
for hash in &all_installed_external_hashes {
planned_addition_keys.insert(external_bundle_key(hash));
if let Some(package_id) = tracked_package_by_hash.get(hash.as_str()) {
planned_addition_keys.insert(external_bundle_key(package_id));
}
}
planned_addition_keys.extend(installed_external_keys(
&all_installed_external_hashes,
&bundle_packages,
));
let installed_keys = planned_addition_keys.clone();
let opt_outs = TypeOptOuts::load(
cluster_id,
&archives,
&addition_eligible_bundles,
overrides,
&installed_keys,
ctx,
)
.await?;
for archive in &archives {
if !addition_eligible_bundles.contains(&archive.manifest.name) {
continue;
}
let suppressed_types = opt_outs.remember(archive, ctx).await?;
for file in &archive.manifest.files {
let suppressed = suppressed_types.contains(&file.content_type());
let file_id = file.kind.package_id();
let user_override = overrides_map
.get(&(archive.manifest.name.clone(), file_id.clone()))
@ -364,6 +371,10 @@ async fn check_bundle_updates_inner(
continue;
}
if suppressed && user_override.is_none() {
continue;
}
if !crate::bundles::effective_enabled(file, user_override) {
if user_override.is_none()
&& file.is_optional_offer()
@ -773,7 +784,11 @@ async fn sync_cluster_loader_version_from_bundles(
let subscribed: HashSet<String> = bundle_packages
.iter()
.filter_map(|bp| bp.bundle_name.clone())
.chain(infer_subscribed_from_archives(&archives, &managed_keys))
.chain(infer_subscribed_from_archives(
&archives,
&HashMap::new(),
&managed_keys,
))
.collect();
let target = select_highest_loader_version(
@ -843,6 +858,33 @@ pub async fn get_bundles_with_update_status(
})
.collect();
// Same liveness the updater uses so an untracked older install is not hidden from the list while it still takes on new files
let live_bundles = addition_eligible_bundles(
ctx,
&archives,
&bundle_packages,
&all_linked,
&overrides,
&overrides_map,
)
.await?;
let (installed_managed_keys, installed_external_hashes) =
installed_bundle_keys(ctx, &all_linked).await?;
let mut installed_keys = installed_managed_keys;
installed_keys.extend(installed_external_keys(
&installed_external_hashes,
&bundle_packages,
));
let opt_outs = TypeOptOuts::load(
cluster_id,
&archives,
&live_bundles,
&overrides,
&installed_keys,
ctx,
)
.await?;
let mut results = Vec::new();
for archive in archives {
@ -873,7 +915,21 @@ pub async fn get_bundles_with_update_status(
files.push((file.clone(), status));
}
let opted_in_types = if live_bundles.contains(&archive.manifest.name) {
let suppressed = opt_outs.suppressed(&archive);
archive
.manifest
.files
.iter()
.map(|f| f.content_type())
.filter(|ct| !suppressed.contains(ct))
.collect()
} else {
HashSet::new()
};
results.push(BundleWithUpdateStatus {
opted_in_types,
archive,
files,
has_updates,
@ -904,41 +960,216 @@ fn bundle_package_key(
}
}
/// Keys both the exact file and the id it was tracked under so a GitHub-hosted file still counts as installed once the bundle moves to a newer release
fn installed_external_keys(
hashes: &HashSet<String>,
bundle_packages: &[BundleTrackedArtifactRow],
) -> HashSet<String> {
let tracked_package_by_hash: HashMap<&str, &str> = bundle_packages
.iter()
.filter_map(|bp| Some((bp.hash.as_str(), bp.package_id.as_deref()?)))
.collect();
hashes
.iter()
.flat_map(|hash| {
std::iter::once(hash.as_str())
.chain(tracked_package_by_hash.get(hash.as_str()).copied())
})
.map(external_bundle_key)
.collect()
}
fn keys_shipped_by_several_bundles<'a>(
archives: impl IntoIterator<Item = &'a BundleArchive>,
) -> HashSet<String> {
let mut counts: HashMap<String, usize> = HashMap::new();
for archive in archives {
let mut seen: HashSet<String> = HashSet::new();
for file in &archive.manifest.files {
let key = file.kind.bundle_key();
if seen.insert(key.clone()) {
*counts.entry(key).or_default() += 1;
}
}
}
counts
.into_iter()
.filter_map(|(key, count)| (count > 1).then_some(key))
.collect()
}
struct TypeOptOuts<'a> {
cluster_id: i64,
overrides: &'a [ClusterBundleOverrideRow],
stray_overrides: Vec<ClusterBundleOverrideRow>,
installed_keys: &'a HashSet<String>,
shared_keys: HashSet<String>,
remembered: HashMap<String, HashSet<ContentType>>,
}
impl<'a> TypeOptOuts<'a> {
async fn load(
cluster_id: i64,
archives: &[BundleArchive],
live: &HashSet<String>,
overrides: &'a [ClusterBundleOverrideRow],
installed_keys: &'a HashSet<String>,
ctx: &ContentCtx,
) -> ContentResult<Self> {
let shipped: HashSet<(&str, String)> = archives
.iter()
.flat_map(|a| {
a.manifest
.files
.iter()
.map(|f| (a.manifest.name.as_str(), f.kind.package_id()))
})
.collect();
let stray_overrides = overrides
.iter()
.filter(|o| !shipped.contains(&(o.bundle_name.as_str(), o.package_id.clone())))
.cloned()
.collect();
let mut remembered: HashMap<String, HashSet<ContentType>> = HashMap::new();
for (bundle_name, content_type) in
bundle_dao::list_type_opt_outs(&ctx.db, cluster_id).await?
{
if let Some(ct) = ContentType::from_repr(content_type as u8) {
remembered.entry(bundle_name).or_default().insert(ct);
}
}
Ok(Self {
cluster_id,
overrides,
stray_overrides,
installed_keys,
shared_keys: keys_shipped_by_several_bundles(
archives.iter().filter(|a| live.contains(&a.manifest.name)),
),
remembered,
})
}
fn signals(&self, archive: &BundleArchive) -> (HashSet<ContentType>, HashSet<ContentType>) {
let bundle_name = &archive.manifest.name;
let mut all_suppressed: HashMap<ContentType, bool> = HashMap::new();
for file in archive.manifest.files.iter().filter(|f| !f.hidden) {
let package_id = file.kind.package_id();
let override_type = find_override(self.overrides, bundle_name, &package_id)
.or_else(|| find_user_suppression(&self.stray_overrides, &package_id));
let key = file.kind.bundle_key();
let installed = self.installed_keys.contains(&key)
|| matches!(&file.kind, BundleFileKind::External { file: ext, .. }
if self.installed_keys.contains(&external_bundle_key(&ext.sha1)));
if override_type.is_none() && !installed {
continue;
}
if self.shared_keys.contains(&key) {
continue;
}
let suppressed = matches!(
override_type,
Some(OverrideType::Disabled | OverrideType::Removed)
);
let entry = all_suppressed.entry(file.content_type()).or_insert(true);
*entry = *entry && suppressed;
}
let seen = all_suppressed.keys().copied().collect();
let suppressed = all_suppressed
.into_iter()
.filter_map(|(ct, all)| all.then_some(ct))
.collect();
(seen, suppressed)
}
fn suppressed(&self, archive: &BundleArchive) -> HashSet<ContentType> {
let (seen, mut suppressed) = self.signals(archive);
suppressed.extend(
self.remembered
.get(&archive.manifest.name)
.into_iter()
.flatten()
.filter(|ct| !seen.contains(ct)),
);
suppressed
}
async fn remember(
&self,
archive: &BundleArchive,
ctx: &ContentCtx,
) -> ContentResult<HashSet<ContentType>> {
let bundle_name = &archive.manifest.name;
let (seen, suppressed) = self.signals(archive);
let remembered = self.remembered.get(bundle_name);
for ct in &seen {
let opted_out = suppressed.contains(ct);
if opted_out != remembered.is_some_and(|r| r.contains(ct)) {
bundle_dao::set_type_opt_out(
&ctx.db,
self.cluster_id,
bundle_name,
*ct as i64,
opted_out,
)
.await?;
}
}
Ok(self.suppressed(archive))
}
}
/// Stricter than subscription
/// emptying a bundle is how a user opts out and an absent override is not
/// consent for a file that did not exist back then
#[tracing::instrument(level = "debug", skip_all)]
async fn addition_eligible_bundles(
ctx: &ContentCtx,
archives: &[BundleArchive],
bundle_packages: &[BundleTrackedArtifactRow],
all_linked: &[LinkedArtifactInfo],
candidate_keys_by_bundle: &HashMap<String, HashSet<String>>,
overrides: &[ClusterBundleOverrideRow],
overrides_map: &HashMap<(String, String), OverrideType>,
) -> ContentResult<HashSet<String>> {
let live: Vec<_> = all_linked.iter().filter(|item| item.enabled).collect();
let (live_managed_keys, _) = installed_bundle_keys(ctx, live).await?;
let mut eligible: HashSet<String> = bundle_packages
.iter()
.filter(|bp| bp.enabled != 0)
.filter_map(|bp| bp.bundle_name.clone())
.collect();
eligible.extend(infer_bundle_names_from_unique_installed_keys(
candidate_keys_by_bundle,
let mut eligible = live_bundle_names(bundle_packages, overrides);
eligible.extend(infer_subscribed_from_archives(
archives,
overrides_map,
&live_managed_keys,
));
eligible.extend(
overrides
.iter()
.filter(|o| OverrideType::parse(&o.override_type) == Some(OverrideType::Enabled))
.map(|o| o.bundle_name.clone()),
);
Ok(eligible)
}
fn live_bundle_names(
bundle_packages: &[BundleTrackedArtifactRow],
overrides: &[ClusterBundleOverrideRow],
) -> HashSet<String> {
bundle_packages
.iter()
.filter(|bp| bp.enabled != 0)
.filter_map(|bp| bp.bundle_name.clone())
.chain(
overrides
.iter()
.filter(|o| OverrideType::parse(&o.override_type) == Some(OverrideType::Enabled))
.map(|o| o.bundle_name.clone()),
)
.collect()
}
#[tracing::instrument(level = "debug", skip_all)]
async fn installed_bundle_keys<'a>(
ctx: &ContentCtx,
@ -1005,13 +1236,17 @@ fn infer_bundle_names_from_unique_installed_keys(
fn infer_subscribed_from_archives(
archives: &[BundleArchive],
overrides_map: &HashMap<(String, String), OverrideType>,
managed_keys: &HashSet<String>,
) -> HashSet<String> {
let mut candidate_keys_by_bundle = HashMap::new();
for archive in archives {
let mut keys = HashSet::new();
for file in &archive.manifest.files {
if !file.enabled {
let user_override = overrides_map
.get(&(archive.manifest.name.clone(), file.kind.package_id()))
.copied();
if !crate::bundles::effective_enabled(file, user_override) {
continue;
}
if let BundleFileKind::Managed {

View file

@ -1,6 +1,7 @@
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
use oneclient_content::bundles::{
BundleFile, BundleFileKind, BundleFileType, BundleManifest, check_bundle_updates,
get_bundles_with_update_status, set_bundle_package_enabled,
};
use oneclient_core::LauncherState;
use oneclient_core::clusters::CreateClusterOptions;
@ -12,6 +13,9 @@ const MC_VERSION: &str = "1.21.1";
const PROJECT_ID: &str = "sodium";
const VERSION_ID: &str = "v1";
const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const SHARED_BUNDLE: &str = "Shared Bundle";
const FABRIC_API_HASH: &str = "dddddddddddddddddddddddddddddddddddddddd";
const HIDDEN_DEP_HASH: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
fn managed_file(enabled: bool) -> BundleFile {
BundleFile {
@ -487,3 +491,805 @@ async fn tracked_bundle_that_never_synced_is_not_removed() {
check.removals_available
);
}
fn resource_pack_file() -> BundleFile {
BundleFile {
enabled: true,
hidden: false,
file_type: BundleFileType::Normal,
path: "resourcepacks/looks.zip".to_string(),
size: 1,
kind: BundleFileKind::Managed {
provider: ProviderId::Modrinth,
project_id: "looks".to_string(),
version_id: "v1".to_string(),
sha1: "cccccccccccccccccccccccccccccccccccccccc".to_string(),
},
}
}
#[tokio::test]
async fn disabling_every_mod_stops_new_mods_while_a_resource_pack_stays_on() {
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
manifest(vec![
managed_file(true),
newly_shipped_file(),
resource_pack_file(),
]),
)
.await
.unwrap();
let cluster_id = cluster_with_tracked_mod(&state).await;
artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0)
.await
.unwrap();
bundle_dao::save_override(
&state.services.db,
cluster_id,
BUNDLE,
PROJECT_ID,
OverrideType::Disabled,
)
.await
.unwrap();
bundle_dao::save_override(
&state.services.db,
cluster_id,
BUNDLE,
"looks",
OverrideType::Enabled,
)
.await
.unwrap();
let check = check_bundle_updates(
cluster_id,
state.bundles.as_ref(),
&state.services.content(),
)
.await
.unwrap();
let added: Vec<String> = check
.additions_available
.iter()
.map(|a| a.new_file.kind.package_id())
.collect();
assert!(
!added.iter().any(|id| id == "newcomer"),
"mods were switched off for this bundle so a new mod must not arrive: {added:?}"
);
assert!(
added.iter().any(|id| id == "looks"),
"the resource pack side of the bundle is still live: {added:?}"
);
assert!(
!check
.optional_available
.iter()
.any(|o| o.package_id == "newcomer"),
"mods were switched off for this bundle so a new mod must not be offered either"
);
let status = get_bundles_with_update_status(
cluster_id,
state.bundles.as_ref(),
&state.services.content(),
)
.await
.unwrap();
let types = &status
.iter()
.find(|b| b.archive.manifest.name == BUNDLE)
.unwrap()
.opted_in_types;
assert!(
!types.contains(&ContentType::Mod) && types.contains(&ContentType::ResourcePack),
"the package list must agree with the update path on which side is opted out: {types:?}"
);
}
fn fabric_api_file() -> BundleFile {
BundleFile {
enabled: true,
hidden: false,
file_type: BundleFileType::Normal,
path: "mods/fabric-api.jar".to_string(),
size: 1,
kind: BundleFileKind::Managed {
provider: ProviderId::Modrinth,
project_id: "fabric-api".to_string(),
version_id: "v1".to_string(),
sha1: FABRIC_API_HASH.to_string(),
},
}
}
fn named_manifest(name: &str, files: Vec<BundleFile>) -> BundleManifest {
let mut m = manifest(files);
m.name = name.to_string();
m
}
async fn install_fabric_api(state: &LauncherState, cluster_id: i64) {
artifact_dao::insert_artifact(
&state.services.db,
FABRIC_API_HASH,
ContentType::Mod as i64,
"artifacts/fabric-api.jar",
"fabric-api.jar",
Some(1),
)
.await
.unwrap();
artifact_dao::link_cluster_artifact(
&state.services.db,
cluster_id,
FABRIC_API_HASH,
"fabric-api.jar",
)
.await
.unwrap();
artifact_dao::upsert_provider_release(
&state.services.db,
ProviderId::Modrinth as i64,
"fabric-api",
"v1",
FABRIC_API_HASH,
"Fabric API",
"1.0.0",
None,
MC_VERSION,
"fabric",
)
.await
.unwrap();
bundle_dao::track_bundle_artifact(
&state.services.db,
cluster_id,
FABRIC_API_HASH,
SHARED_BUNDLE,
"v1",
"fabric-api",
)
.await
.unwrap();
}
#[tokio::test]
async fn a_mod_every_bundle_ships_does_not_keep_a_disabled_bundle_taking_new_mods() {
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
named_manifest(
BUNDLE,
vec![managed_file(true), newly_shipped_file(), fabric_api_file()],
),
)
.await
.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
named_manifest(SHARED_BUNDLE, vec![fabric_api_file()]),
)
.await
.unwrap();
let cluster_id = cluster_with_tracked_mod(&state).await;
install_fabric_api(&state, cluster_id).await;
artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0)
.await
.unwrap();
bundle_dao::save_override(
&state.services.db,
cluster_id,
BUNDLE,
PROJECT_ID,
OverrideType::Disabled,
)
.await
.unwrap();
let check = check_bundle_updates(
cluster_id,
state.bundles.as_ref(),
&state.services.content(),
)
.await
.unwrap();
let added: Vec<String> = check
.additions_available
.iter()
.map(|a| a.new_file.kind.package_id())
.collect();
assert!(
!added.iter().any(|id| id == "newcomer"),
"a mod shared with another bundle must not hold this bundle open: {added:?}"
);
}
fn hidden_dependency_file() -> BundleFile {
BundleFile {
enabled: true,
hidden: true,
file_type: BundleFileType::Normal,
path: "mods/sodium-extra-lib.jar".to_string(),
size: 1,
kind: BundleFileKind::Managed {
provider: ProviderId::Modrinth,
project_id: "sodium-extra-lib".to_string(),
version_id: "v1".to_string(),
sha1: HIDDEN_DEP_HASH.to_string(),
},
}
}
#[tokio::test]
async fn a_hidden_dependency_only_this_bundle_ships_does_not_hold_it_open() {
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
manifest(vec![
managed_file(true),
newly_shipped_file(),
hidden_dependency_file(),
]),
)
.await
.unwrap();
let cluster_id = cluster_with_tracked_mod(&state).await;
artifact_dao::insert_artifact(
&state.services.db,
HIDDEN_DEP_HASH,
ContentType::Mod as i64,
"artifacts/sodium-extra-lib.jar",
"sodium-extra-lib.jar",
Some(1),
)
.await
.unwrap();
artifact_dao::link_cluster_artifact(
&state.services.db,
cluster_id,
HIDDEN_DEP_HASH,
"sodium-extra-lib.jar",
)
.await
.unwrap();
artifact_dao::upsert_provider_release(
&state.services.db,
ProviderId::Modrinth as i64,
"sodium-extra-lib",
"v1",
HIDDEN_DEP_HASH,
"Sodium Extra Lib",
"1.0.0",
None,
MC_VERSION,
"fabric",
)
.await
.unwrap();
bundle_dao::track_bundle_artifact(
&state.services.db,
cluster_id,
HIDDEN_DEP_HASH,
BUNDLE,
"v1",
"sodium-extra-lib",
)
.await
.unwrap();
artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0)
.await
.unwrap();
bundle_dao::save_override(
&state.services.db,
cluster_id,
BUNDLE,
PROJECT_ID,
OverrideType::Disabled,
)
.await
.unwrap();
let check = check_bundle_updates(
cluster_id,
state.bundles.as_ref(),
&state.services.content(),
)
.await
.unwrap();
let added: Vec<String> = check
.additions_available
.iter()
.map(|a| a.new_file.kind.package_id())
.collect();
assert!(
!added.iter().any(|id| id == "newcomer"),
"a dependency the user was never shown must not read as wanting the bundle: {added:?}"
);
}
#[tokio::test]
async fn an_unrelated_catalog_bundle_sharing_a_mod_does_not_undo_the_opt_out() {
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
named_manifest(
BUNDLE,
vec![
managed_file(true),
newly_shipped_file(),
resource_pack_file(),
],
),
)
.await
.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
named_manifest("Unrelated Bundle", vec![managed_file(true)]),
)
.await
.unwrap();
let cluster_id = cluster_with_tracked_mod(&state).await;
artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0)
.await
.unwrap();
bundle_dao::save_override(
&state.services.db,
cluster_id,
BUNDLE,
PROJECT_ID,
OverrideType::Disabled,
)
.await
.unwrap();
bundle_dao::save_override(
&state.services.db,
cluster_id,
BUNDLE,
"looks",
OverrideType::Enabled,
)
.await
.unwrap();
let check = check_bundle_updates(
cluster_id,
state.bundles.as_ref(),
&state.services.content(),
)
.await
.unwrap();
let added: Vec<String> = check
.additions_available
.iter()
.map(|a| a.new_file.kind.package_id())
.collect();
assert!(
!added.iter().any(|id| id == "newcomer"),
"a bundle the user never installed must not speak for this one: {added:?}"
);
}
#[tokio::test]
async fn untracked_older_install_counts_as_opted_in() {
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
manifest(vec![managed_file(true), newly_shipped_file()]),
)
.await
.unwrap();
let cluster_id = cluster_with_tracked_mod(&state).await;
bundle_dao::clear_bundle_tracking(&state.services.db, cluster_id, HASH)
.await
.unwrap();
let bundles = get_bundles_with_update_status(
cluster_id,
state.bundles.as_ref(),
&state.services.content(),
)
.await
.unwrap();
assert!(
bundles
.iter()
.all(|b| b.opted_in_types.contains(&ContentType::Mod)),
"a bundle the updater infers from its installed mods must not have its files hidden from the All tab"
);
}
#[tokio::test]
async fn package_list_and_updater_infer_the_same_bundle_through_overrides() {
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
named_manifest(BUNDLE, vec![managed_file(true), newly_shipped_file()]),
)
.await
.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
named_manifest(SHARED_BUNDLE, vec![managed_file(true)]),
)
.await
.unwrap();
let cluster_id = cluster_with_tracked_mod(&state).await;
bundle_dao::clear_bundle_tracking(&state.services.db, cluster_id, HASH)
.await
.unwrap();
bundle_dao::save_override(
&state.services.db,
cluster_id,
SHARED_BUNDLE,
PROJECT_ID,
OverrideType::Disabled,
)
.await
.unwrap();
let check = check_bundle_updates(
cluster_id,
state.bundles.as_ref(),
&state.services.content(),
)
.await
.unwrap();
assert!(
check
.additions_available
.iter()
.any(|a| a.new_file.kind.package_id() == "newcomer"),
"the updater infers this bundle from its uniquely installed mod"
);
let status = get_bundles_with_update_status(
cluster_id,
state.bundles.as_ref(),
&state.services.content(),
)
.await
.unwrap();
let types = &status
.iter()
.find(|b| b.archive.manifest.name == BUNDLE)
.unwrap()
.opted_in_types;
assert!(
types.contains(&ContentType::Mod),
"the package list must infer the same bundle the updater does: {types:?}"
);
}
const GITHUB_MOD_ID: &str = "github-mod";
const GITHUB_OLD_HASH: &str = "1111111111111111111111111111111111111111";
/// The catalog has moved on to a newer release than the one installed
fn github_mod_file() -> BundleFile {
BundleFile {
enabled: true,
hidden: false,
file_type: BundleFileType::Normal,
path: "mods/github-mod.jar".to_string(),
size: 1,
kind: BundleFileKind::External {
file: oneclient_content::packages::types::ExternalFile {
name: "github-mod.jar".to_string(),
url: "https://github.com/example/github-mod/releases/download/v2/github-mod.jar"
.to_string(),
sha1: "2222222222222222222222222222222222222222".to_string(),
size: 1,
content_type: ContentType::Mod,
},
id: Some(GITHUB_MOD_ID.to_string()),
meta: None,
},
}
}
#[tokio::test]
async fn an_outdated_github_mod_left_on_keeps_the_bundle_taking_mods() {
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
manifest(vec![
managed_file(true),
github_mod_file(),
newly_shipped_file(),
]),
)
.await
.unwrap();
let cluster_id = cluster_with_tracked_mod(&state).await;
let db = &state.services.db;
artifact_dao::update_cluster_artifact(db, cluster_id, HASH, "sodium.jar", 0)
.await
.unwrap();
bundle_dao::save_override(db, cluster_id, BUNDLE, PROJECT_ID, OverrideType::Disabled)
.await
.unwrap();
artifact_dao::insert_artifact(
db,
GITHUB_OLD_HASH,
ContentType::Mod as i64,
"artifacts/github-mod.jar",
"github-mod.jar",
Some(1),
)
.await
.unwrap();
artifact_dao::link_cluster_artifact(db, cluster_id, GITHUB_OLD_HASH, "github-mod.jar")
.await
.unwrap();
bundle_dao::track_bundle_artifact(
db,
cluster_id,
GITHUB_OLD_HASH,
BUNDLE,
GITHUB_OLD_HASH,
GITHUB_MOD_ID,
)
.await
.unwrap();
let check = check_bundle_updates(
cluster_id,
state.bundles.as_ref(),
&state.services.content(),
)
.await
.unwrap();
assert!(
check
.additions_available
.iter()
.any(|a| a.new_file.kind.package_id() == "newcomer"),
"the GitHub mod is still on even though its installed release is older than the catalog's"
);
let status = get_bundles_with_update_status(
cluster_id,
state.bundles.as_ref(),
&state.services.content(),
)
.await
.unwrap();
let types = &status
.iter()
.find(|b| b.archive.manifest.name == BUNDLE)
.unwrap()
.opted_in_types;
assert!(types.contains(&ContentType::Mod), "{types:?}");
}
async fn cluster_with_mods_opted_out(state: &LauncherState, disabled_under: &str) -> i64 {
let cluster_id = cluster_with_tracked_mod(state).await;
artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0)
.await
.unwrap();
bundle_dao::save_override(
&state.services.db,
cluster_id,
disabled_under,
PROJECT_ID,
OverrideType::Disabled,
)
.await
.unwrap();
bundle_dao::save_override(
&state.services.db,
cluster_id,
BUNDLE,
"looks",
OverrideType::Enabled,
)
.await
.unwrap();
cluster_id
}
async fn added_ids(state: &LauncherState, cluster_id: i64) -> Vec<String> {
check_bundle_updates(
cluster_id,
state.bundles.as_ref(),
&state.services.content(),
)
.await
.unwrap()
.additions_available
.iter()
.map(|a| a.new_file.kind.package_id())
.collect()
}
#[tokio::test]
async fn a_disable_filed_under_another_bundle_still_opts_the_mods_out() {
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
manifest(vec![
managed_file(true),
newly_shipped_file(),
resource_pack_file(),
]),
)
.await
.unwrap();
let cluster_id = cluster_with_mods_opted_out(&state, "Old Bundle").await;
let added = added_ids(&state, cluster_id).await;
assert!(
!added.iter().any(|id| id == "newcomer"),
"the user switched sodium off while it was filed under another bundle: {added:?}"
);
}
#[tokio::test]
async fn the_catalog_dropping_every_switched_off_mod_keeps_the_opt_out() {
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
manifest(vec![managed_file(true), resource_pack_file()]),
)
.await
.unwrap();
let cluster_id = cluster_with_mods_opted_out(&state, BUNDLE).await;
added_ids(&state, cluster_id).await;
oneclient_core::dev::seed_bundle_archive(
&state,
manifest(vec![newly_shipped_file(), resource_pack_file()]),
)
.await
.unwrap();
let added = added_ids(&state, cluster_id).await;
assert!(
!added.iter().any(|id| id == "newcomer"),
"mods stay opted out after the catalog replaces them: {added:?}"
);
let status = get_bundles_with_update_status(
cluster_id,
state.bundles.as_ref(),
&state.services.content(),
)
.await
.unwrap();
let types = &status
.iter()
.find(|b| b.archive.manifest.name == BUNDLE)
.unwrap()
.opted_in_types;
assert!(
!types.contains(&ContentType::Mod),
"the package list must remember the opt-out too: {types:?}"
);
set_bundle_package_enabled(
cluster_id,
BUNDLE,
"looks",
true,
true,
&state.services.content(),
)
.await
.unwrap();
let added = added_ids(&state, cluster_id).await;
assert!(
!added.iter().any(|id| id == "newcomer"),
"switching a resource pack on must not opt the mods back in: {added:?}"
);
set_bundle_package_enabled(
cluster_id,
BUNDLE,
"newcomer",
true,
true,
&state.services.content(),
)
.await
.unwrap();
let added = added_ids(&state, cluster_id).await;
assert!(
added.iter().any(|id| id == "newcomer"),
"switching a mod back on opts the mods back in: {added:?}"
);
}
#[tokio::test]
async fn switching_a_new_mod_on_beside_switched_off_ones_installs_it() {
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
manifest(vec![
managed_file(true),
newly_shipped_file(),
resource_pack_file(),
]),
)
.await
.unwrap();
let cluster_id = cluster_with_mods_opted_out(&state, BUNDLE).await;
added_ids(&state, cluster_id).await;
set_bundle_package_enabled(
cluster_id,
BUNDLE,
"newcomer",
true,
true,
&state.services.content(),
)
.await
.unwrap();
let added = added_ids(&state, cluster_id).await;
assert!(
added.iter().any(|id| id == "newcomer"),
"sodium staying off must not swallow the user switching newcomer on: {added:?}"
);
}
#[tokio::test]
async fn the_package_list_does_not_save_the_opt_out() {
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
oneclient_core::dev::seed_bundle_archive(
&state,
manifest(vec![managed_file(true), resource_pack_file()]),
)
.await
.unwrap();
let cluster_id = cluster_with_mods_opted_out(&state, BUNDLE).await;
let status = get_bundles_with_update_status(
cluster_id,
state.bundles.as_ref(),
&state.services.content(),
)
.await
.unwrap();
let types = &status
.iter()
.find(|b| b.archive.manifest.name == BUNDLE)
.unwrap()
.opted_in_types;
assert!(!types.contains(&ContentType::Mod), "{types:?}");
assert!(
bundle_dao::list_type_opt_outs(&state.services.db, cluster_id)
.await
.unwrap()
.is_empty(),
"only the updater saves the opt-out"
);
added_ids(&state, cluster_id).await;
assert!(
!bundle_dao::list_type_opt_outs(&state.services.db, cluster_id)
.await
.unwrap()
.is_empty()
);
}

View file

@ -0,0 +1,7 @@
CREATE TABLE cluster_bundle_type_opt_outs (
cluster_id INTEGER NOT NULL,
bundle_name TEXT NOT NULL,
content_type INTEGER NOT NULL,
PRIMARY KEY (cluster_id, bundle_name, content_type),
FOREIGN KEY (cluster_id) REFERENCES clusters (id) ON DELETE CASCADE
);

View file

@ -243,3 +243,58 @@ pub async fn list_overrides(
.fetch_all(pool)
.await
}
pub async fn list_type_opt_outs(
pool: &SqlitePool,
cluster_id: i64,
) -> Result<Vec<(String, i64)>, sqlx::Error> {
let rows = sqlx::query!(
r#"
SELECT bundle_name, content_type
FROM cluster_bundle_type_opt_outs
WHERE cluster_id = ?
"#,
cluster_id
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| (r.bundle_name, r.content_type))
.collect())
}
pub async fn set_type_opt_out(
pool: &SqlitePool,
cluster_id: i64,
bundle_name: &str,
content_type: i64,
opted_out: bool,
) -> Result<(), sqlx::Error> {
if opted_out {
sqlx::query!(
r#"
INSERT OR IGNORE INTO cluster_bundle_type_opt_outs (cluster_id, bundle_name, content_type)
VALUES (?, ?, ?)
"#,
cluster_id,
bundle_name,
content_type
)
.execute(pool)
.await?;
} else {
sqlx::query!(
r#"
DELETE FROM cluster_bundle_type_opt_outs
WHERE cluster_id = ? AND bundle_name = ? AND content_type = ?
"#,
cluster_id,
bundle_name,
content_type
)
.execute(pool)
.await?;
}
Ok(())
}

View file

@ -64,6 +64,10 @@ pub fn java_arguments(
parsed.extend(performance_flags(java_major, java_arch, mem_max, &custom));
if java_major >= 21 {
parsed.push("--add-modules=jdk.incubator.vector".to_string());
}
parsed.push(format!("-Xmx{mem_max}M"));
parsed.extend(custom);
@ -893,7 +897,7 @@ mod tests {
let cp = classpaths(&dir, &libraries, &client, "x86", false).unwrap();
assert!(
!cp.contains("asm-all"),
!cp.contains("asm-all-4.1.jar"),
"fabric aborts on two copies of org/objectweb/asm/ClassReader.class: {cp}"
);
assert!(cp.contains("asm-9.10.1.jar"), "{cp}");