feat: added support for github hosted bundle mods (#883)

* feat: added support for github hosted bundle mods

* fix: keep external bundle mods when their manifest entry gains an id

* fix: match a pending external bundle update to its installed jar

* chore: parse the bundle file host with url instead of by hand

* fix: namespace external bundle file ids under ext:

* chore: rename the Browser tab to Online and list unbundled GitHub mods there

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
This commit is contained in:
Maciek 2026-09-28 17:30:50 +02:00 • committed by GitHub
parent abc452f294
commit b3b436bc3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 484 additions and 216 deletions

View file

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>

After

Width:  |  Height:  |  Size: 678 B

View file

@ -110,7 +110,11 @@ impl Component for ClusterUpdatePopup {
})
.collect();
let mut meta = MetaMap::new();
for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() {
for provider in ProviderId::REMOTE_PROVIDERS
.iter()
.copied()
.chain([ProviderId::Local])
{
let ids: Vec<String> = all_items
.iter()
.filter(|i| i.provider == provider)

View file

@ -115,6 +115,7 @@ pub enum IconType {
Folder,
FolderCheck,
FolderDownload,
Github,
Globe01,
HelpCircle,
IconLogo,

View file

@ -81,7 +81,9 @@ pub use optional_mods_popup::OptionalModsPopup;
pub use overlay_popup::{OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL, OverlayPopup, overlay_is_topmost};
pub use package_delete_dialog::use_shared_delete;
pub(crate) use package_row::{CARD_GRID_H, GRID_GAP, GRID_MIN_W, grid_card, package_icon};
pub use package_row::{CardLayout, PackageEntry, PackageRow, package_context_menu, provider_badge};
pub use package_row::{
CardLayout, PackageEntry, PackageRow, github_badge, package_context_menu, provider_badge,
};
pub use package_update_popup::PackageUpdatePopup;
pub use pagination::Pagination;
pub use player_model::PlayerModel;

View file

@ -42,7 +42,11 @@ impl Component for OptionalModsPopup {
let groups = snapshot.optional_mods.clone();
let mut meta = MetaMap::new();
for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() {
for provider in ProviderId::REMOTE_PROVIDERS
.iter()
.copied()
.chain([ProviderId::Local])
{
let ids: Vec<String> = groups
.iter()
.flatten()
@ -100,6 +104,7 @@ fn entry_from_item(
package_id,
bundle_name: item.offer.as_ref().map(|(bundle, _)| bundle.clone()),
provider: item.provider,
github_hosted: item.github_hosted,
name: cached
.map(|cached| cached.name.clone())
.filter(|name| !name.is_empty())

View file

@ -37,6 +37,7 @@ pub struct PackageEntry {
pub package_id: String,
pub bundle_name: Option<String>,
pub provider: ProviderId,
pub github_hosted: bool,
pub name: String,
pub file_name: String,
pub author: String,
@ -398,7 +399,9 @@ fn grid_meta(
) -> Element {
let muted = CARD_NAME.with_a(scale_a(alpha, 0.5));
let source = if item.is_remote() && navigable {
let source = if item.github_hosted {
meta_text("GitHub".to_string(), muted)
} else if item.is_remote() && navigable {
SourceLink {
provider: item.provider,
package_id: item.package_id.clone(),
@ -615,7 +618,9 @@ fn package_info(
.max_width(Size::percent(60.))
.color(CARD_NAME),
)
.child(if remote {
.child(if item.github_hosted {
github_badge()
} else if remote {
provider_badge(item.provider)
} else {
local_badge()
@ -704,6 +709,13 @@ pub fn provider_badge(provider: ProviderId) -> Element {
)
}
pub fn github_badge() -> Element {
badge(
Icon::new(IconType::Github).size(12.).into_element(),
"GitHub".to_string(),
)
}
fn outdated_badge() -> Element {
accent_badge(
Icon::new(IconType::RefreshCw01)

View file

@ -46,14 +46,16 @@ pub fn item_from_bundle_file(file: &oneclient_core::BundleFile) -> ClusterUpdate
..
} => ClusterUpdateItem {
provider: *provider,
github_hosted: false,
project_id: Some(project_id.clone()),
fallback: file.display_name(),
offer: None,
status: None,
},
oneclient_core::BundleFileKind::External(_) => ClusterUpdateItem {
oneclient_core::BundleFileKind::External { .. } => ClusterUpdateItem {
provider: oneclient_content::packages::ProviderId::Local,
project_id: None,
github_hosted: file.is_github_hosted(),
project_id: Some(file.kind.metadata_id()),
fallback: file.display_name(),
offer: None,
status: None,
@ -90,6 +92,7 @@ async fn cluster_update_summary(
provider: r
.provider
.unwrap_or(oneclient_content::packages::ProviderId::Local),
github_hosted: false,
project_id: r.project_id.clone(),
fallback: r
.display_name

View file

@ -47,6 +47,7 @@ pub enum OptionalModsOutcome {
#[derive(Clone, Debug, PartialEq)]
pub struct ClusterUpdateItem {
pub provider: ProviderId,
pub github_hosted: bool,
pub project_id: Option<String>,
/// Used when the meta cache has no entry (file name / package id)
pub fallback: String,
@ -58,6 +59,7 @@ impl ClusterUpdateItem {
pub fn from_name(name: impl Into<String>) -> Self {
Self {
provider: ProviderId::Local,
github_hosted: false,
project_id: None,
fallback: name.into(),
offer: None,

View file

@ -3,7 +3,9 @@ use std::collections::{HashMap, HashSet};
use freya::prelude::*;
use oneclient_common::search::{MatchScore, SearchQuery};
use oneclient_content::packages::{CachedPackageMeta, ContentType, ProviderId};
use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo};
use oneclient_core::{
BundleFileKind, BundleWithUpdateStatus, FileUpdateStatus, LinkedArtifactInfo,
};
use oneclient_db::models::OverrideType;
use crate::components::{CARD_GRID_H, CardLayout, GRID_GAP, GRID_MIN_W, PackageEntry};
@ -52,11 +54,22 @@ fn provider_project_ids(
ids
}
fn local_project_ids(content: &[LinkedArtifactInfo], content_type: ContentType) -> Vec<String> {
fn local_project_ids(
content: &[LinkedArtifactInfo],
bundles: &[BundleWithUpdateStatus],
content_type: ContentType,
) -> Vec<String> {
let bundled = bundles
.iter()
.flat_map(|bundle| &bundle.files)
.filter(|(file, _status)| file.content_type() == content_type)
.filter(|(file, _status)| matches!(file.kind, BundleFileKind::External { .. }))
.map(|(file, _status)| file.kind.metadata_id());
content
.iter()
.filter(|info| info.content_type == content_type && info.provider.is_none())
.map(|info| info.hash.clone())
.chain(bundled)
.collect()
}
@ -74,7 +87,10 @@ pub fn use_content_meta(
}
}
let local = use_package_meta_batch(ProviderId::Local, local_project_ids(content, content_type));
let local = use_package_meta_batch(
ProviderId::Local,
local_project_ids(content, bundles, content_type),
);
for (hash, meta) in package_meta_batch(&local) {
out.insert((ProviderId::Local, hash), meta);
}
@ -117,7 +133,7 @@ pub fn bundle_packages(
for bundle in bundles {
let bundle_name = &bundle.archive.manifest.name;
let category = bundle.archive.manifest.category.clone();
for (file, _status) in &bundle.files {
for (file, status) in &bundle.files {
if file.content_type() != content_type {
continue;
}
@ -130,14 +146,24 @@ pub fn bundle_packages(
continue;
}
let provider = match &file.kind {
BundleFileKind::Managed { provider, .. } => *provider,
BundleFileKind::External(_) => ProviderId::Local,
};
let installed_info = by_project
.get(pid.as_str())
.or_else(|| by_hash.get(pid.as_str()))
.copied();
let provider = file.kind.metadata_provider();
let installed_info = match &file.kind {
BundleFileKind::Managed { .. } => by_project
.get(pid.as_str())
.or_else(|| by_hash.get(pid.as_str())),
BundleFileKind::External { file: ext, .. } => {
let hash = match status {
FileUpdateStatus::UpdateAvailable {
installed_version_id,
..
} => installed_version_id,
_ => &ext.sha1,
};
seen.insert(hash.clone());
by_hash.get(hash.as_str())
}
}
.copied();
let ov = overrides
.get(&(bundle_name.clone(), pid.clone()))
.map(String::as_str);
@ -160,8 +186,9 @@ pub fn bundle_packages(
categories,
enabled,
file.enabled,
file.is_github_hosted(),
installed_info,
meta,
meta.get(&(provider, file.kind.metadata_id())),
file.display_name(),
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
@ -179,6 +206,7 @@ pub fn bundle_packages(
let provider = info.provider.unwrap_or(ProviderId::Local);
let pid = info.project_id.clone().unwrap_or_else(|| info.hash.clone());
let outdated = stale.contains(&info.hash);
let row_meta = meta.get(&(provider, pid.clone()));
rows.push(make_row(
pid,
None,
@ -187,8 +215,9 @@ pub fn bundle_packages(
Vec::new(),
info.enabled,
true,
false,
Some(info),
meta,
row_meta,
info.display_name
.clone()
.unwrap_or_else(|| info.file_name.clone()),
@ -209,13 +238,13 @@ fn make_row(
categories: Vec<String>,
enabled: bool,
manifest_default: bool,
github_hosted: bool,
installed_info: Option<&LinkedArtifactInfo>,
meta: &PackageMetaMap,
m: Option<&CachedPackageMeta>,
fallback_name: String,
update_available: bool,
hidden: bool,
) -> PackageEntry {
let m = meta.get(&(provider, package_id.clone()));
let name = m
.map(|p| p.name.clone())
.filter(|s| !s.is_empty())
@ -237,6 +266,7 @@ fn make_row(
package_id,
bundle_name,
provider,
github_hosted,
name,
file_name,
author,
@ -268,7 +298,7 @@ impl Tab {
match self {
Tab::All => "All".to_string(),
Tab::Category(c) => c.clone(),
Tab::Browser => "Browser".to_string(),
Tab::Browser => "Online".to_string(),
Tab::Local => "Local".to_string(),
}
}
@ -277,8 +307,8 @@ impl Tab {
match self {
Tab::All => true,
Tab::Category(c) => p.categories.iter().any(|pc| pc == c),
Tab::Browser => p.is_remote() && !p.in_bundle(),
Tab::Local => !p.is_remote(),
Tab::Browser => (p.is_remote() || p.github_hosted) && !p.in_bundle(),
Tab::Local => !p.is_remote() && !p.github_hosted,
}
}
}
@ -331,7 +361,7 @@ fn build_tabs(categories: &[String], items: &[PackageEntry], hidden: HiddenFilte
}
}
// Category tabs are hidden when empty All + Browser + Local are always shown
// Category tabs are hidden when empty All + Online + Local are always shown
let mut tabs: Vec<Tab> = vec![Tab::All];
tabs.extend(
cats.into_iter()

View file

@ -2,8 +2,8 @@ use std::collections::{HashMap, HashSet};
use freya::prelude::*;
use oneclient_content::packages::ProviderId;
use oneclient_core::BundleArchive;
use oneclient_core::clusters::Cluster;
use oneclient_core::{BundleArchive, BundleFile, BundleFileKind};
use crate::components::ScrollArea;
use crate::hooks::{
@ -67,13 +67,6 @@ fn fps_warning_banner() -> impl IntoElement {
.child(text)
}
fn file_provider(file: &BundleFile) -> ProviderId {
match &file.kind {
BundleFileKind::Managed { provider, .. } => *provider,
BundleFileKind::External(_) => ProviderId::Local,
}
}
fn bundle_display_name(archive: &BundleArchive) -> String {
let category = archive.manifest.category.trim();
if category.is_empty() {
@ -149,7 +142,9 @@ fn opt_in_bundles(clusters: &[ClusterBundles]) -> Vec<OptInBundle> {
#[derive(Clone)]
struct OptionalMod {
package_id: String,
metadata_id: String,
provider: ProviderId,
github_hosted: bool,
fallback_name: String,
size: u64,
/// `(cluster_id, bundle_name)` pairs this mod is available from
@ -186,7 +181,9 @@ fn optional_mods(clusters: &[ClusterBundles]) -> Vec<OptionalMod> {
order.push(package_id.clone());
OptionalMod {
package_id: package_id.clone(),
provider: file_provider(file),
metadata_id: file.kind.metadata_id(),
provider: file.kind.metadata_provider(),
github_hosted: file.is_github_hosted(),
fallback_name: file.display_name(),
size: file.size,
locations: Vec::new(),
@ -259,9 +256,10 @@ impl Component for OnboardingBundles {
let extras = optional_mods(&clusters);
// Hooks so they run every render regardless of whether there are extras yet
let (mr_ids, cf_ids) = collect_ids(&extras);
let (mr_ids, cf_ids, local_ids) = collect_ids(&extras);
let mr_meta = package_meta_batch(&use_package_meta_batch(ProviderId::Modrinth, mr_ids));
let cf_meta = package_meta_batch(&use_package_meta_batch(ProviderId::CurseForge, cf_ids));
let local_meta = package_meta_batch(&use_package_meta_batch(ProviderId::Local, local_ids));
let mut sections: Vec<Element> = Vec::new();
@ -278,6 +276,7 @@ impl Component for OnboardingBundles {
user_touched,
&mr_meta,
&cf_meta,
&local_meta,
));
}
@ -441,14 +440,15 @@ fn extras_section(
user_touched: State<bool>,
mr_meta: &MetaMap,
cf_meta: &MetaMap,
local_meta: &MetaMap,
) -> Element {
let cards: Vec<Element> = extras
.iter()
.map(|entry| {
let meta = match entry.provider {
ProviderId::Modrinth => mr_meta.get(&entry.package_id),
ProviderId::CurseForge => cf_meta.get(&entry.package_id),
ProviderId::Local => None,
ProviderId::Modrinth => mr_meta.get(&entry.metadata_id),
ProviderId::CurseForge => cf_meta.get(&entry.metadata_id),
ProviderId::Local => local_meta.get(&entry.metadata_id),
};
let name = meta
.map(|m| m.name.clone())
@ -461,6 +461,7 @@ fn extras_section(
OnboardingModCard {
provider: entry.provider,
github_hosted: entry.github_hosted,
name,
author,
description,
@ -518,15 +519,16 @@ fn extras_section(
.into_element()
}
fn collect_ids(extras: &[OptionalMod]) -> (Vec<String>, Vec<String>) {
fn collect_ids(extras: &[OptionalMod]) -> (Vec<String>, Vec<String>, Vec<String>) {
let mut mr = Vec::new();
let mut cf = Vec::new();
let mut local = Vec::new();
for entry in extras {
match entry.provider {
ProviderId::Modrinth => mr.push(entry.package_id.clone()),
ProviderId::CurseForge => cf.push(entry.package_id.clone()),
ProviderId::Local => {}
ProviderId::Modrinth => mr.push(entry.metadata_id.clone()),
ProviderId::CurseForge => cf.push(entry.metadata_id.clone()),
ProviderId::Local => local.push(entry.metadata_id.clone()),
}
}
(mr, cf)
(mr, cf, local)
}

View file

@ -1,7 +1,7 @@
use freya::prelude::*;
use oneclient_content::packages::ProviderId;
use crate::components::{Icon, provider_badge};
use crate::components::{Icon, IconType, github_badge, provider_badge};
use crate::hooks::{loaded_image, use_cached_image};
use crate::theme::colors;
use crate::ui::{ImageFallbackExt, border_all_color};
@ -16,6 +16,7 @@ pub(super) const GRID_GAP: f32 = 10.;
#[derive(PartialEq)]
pub(super) struct OnboardingModCard {
pub(super) provider: ProviderId,
pub(super) github_hosted: bool,
pub(super) name: String,
pub(super) author: String,
pub(super) description: String,
@ -36,10 +37,10 @@ impl Component for OnboardingModCard {
.height(Size::px(44.))
.aspect_ratio(AspectRatio::Min)
.corner_radius(CornerRadius::new_all(8.))
.fallback(icon_box(self.provider))
.fallback(icon_box(self.placeholder_icon()))
.into_element(),
None => icon_box(self.provider).into_element(),
None => icon_box(self.placeholder_icon()).into_element(),
};
let (bg, border, alpha) = if self.enabled {
@ -82,7 +83,11 @@ impl Component for OnboardingModCard {
.color(colors::fg_secondary().with_a(alpha)),
)
})
.child(provider_badge(self.provider)),
.child(if self.github_hosted {
github_badge()
} else {
provider_badge(self.provider)
}),
);
let description = (!self.description.is_empty()).then(|| {
@ -132,14 +137,24 @@ impl Component for OnboardingModCard {
}
}
fn icon_box(provider: ProviderId) -> impl IntoElement {
impl OnboardingModCard {
fn placeholder_icon(&self) -> IconType {
if self.github_hosted {
IconType::Github
} else {
self.provider.into()
}
}
}
fn icon_box(icon: IconType) -> impl IntoElement {
rect()
.center()
.width(Size::px(44.))
.height(Size::px(44.))
.corner_radius(CornerRadius::new_all(8.))
.background(colors::component_bg())
.child(Icon::new(provider).size(20.).color(colors::fg_secondary()))
.child(Icon::new(icon).size(20.).color(colors::fg_secondary()))
.into_element()
}

View file

@ -10,13 +10,17 @@ pub fn file(package_id: &str, enabled: bool, hidden: bool) -> BundleFile {
hidden,
path: format!("mods/{package_id}.jar"),
size: 1,
kind: BundleFileKind::External(ExternalFile {
name: format!("{package_id}.jar"),
url: format!("https://example.invalid/{package_id}.jar"),
sha1: package_id.to_string(),
size: 1,
content_type: ContentType::Mod,
}),
kind: BundleFileKind::External {
file: ExternalFile {
name: format!("{package_id}.jar"),
url: format!("https://example.invalid/{package_id}.jar"),
sha1: package_id.to_string(),
size: 1,
content_type: ContentType::Mod,
},
id: None,
meta: None,
},
}
}

View file

@ -120,7 +120,7 @@ pub async fn install_package_from_bundle(
.await?;
artifact.hash
}
BundleFileKind::External(ext) => {
BundleFileKind::External { file: ext, .. } => {
install_external(ext, &cluster, skip_compatibility, child, ctx).await?
}
};
@ -239,6 +239,74 @@ async fn clear_reconciler_disables(
Ok(())
}
pub(crate) fn external_ids_by_sha1(
archives: &[BundleArchive],
) -> std::collections::HashMap<String, String> {
archives
.iter()
.flat_map(|archive| &archive.manifest.files)
.filter_map(|file| match &file.kind {
BundleFileKind::External {
file: ext,
id: Some(id),
..
} => Some((ext.sha1.clone(), id.clone())),
_ => None,
})
.collect()
}
async fn adopt_external_ids(
cluster_id: i64,
archives: &[BundleArchive],
ctx: &ContentCtx,
) -> ContentResult<()> {
let ids = external_ids_by_sha1(archives);
if ids.is_empty() {
return Ok(());
}
for row in bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await? {
let (Some(bundle_name), Some(version_id), Some(package_id)) =
(&row.bundle_name, &row.bundle_version_id, &row.package_id)
else {
continue;
};
if *package_id != row.hash {
continue;
}
let Some(id) = ids.get(package_id) else {
continue;
};
bundle_dao::track_bundle_artifact(
&ctx.db,
cluster_id,
&row.hash,
bundle_name,
version_id,
id,
)
.await?;
}
let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?;
for row in &overrides {
let Some(id) = ids.get(&row.package_id) else {
continue;
};
let superseded = overrides
.iter()
.any(|other| other.bundle_name == row.bundle_name && other.package_id == *id);
if !superseded && let Some(override_type) = OverrideType::parse(&row.override_type) {
bundle_dao::save_override(&ctx.db, cluster_id, &row.bundle_name, id, override_type)
.await?;
}
bundle_dao::remove_override(&ctx.db, cluster_id, &row.bundle_name, &row.package_id).await?;
}
Ok(())
}
#[tracing::instrument(level = "debug", skip(archives, ctx))]
pub async fn heal_bundle_activity(
cluster_id: i64,
@ -246,6 +314,7 @@ pub async fn heal_bundle_activity(
ctx: &ContentCtx,
) -> ContentResult<()> {
clear_reconciler_disables(cluster_id, archives, ctx).await?;
adopt_external_ids(cluster_id, archives, ctx).await?;
let tracked = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?;
if tracked.iter().all(|row| row.enabled != 0) {
@ -332,18 +401,10 @@ pub async fn install_enabled_bundle_files(
let bundle_name = archive.manifest.name.clone();
let mut installed = Vec::new();
let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?;
let mut linked_projects: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut linked_hashes: std::collections::HashSet<&str> = std::collections::HashSet::new();
// "Already installed" means the database not disk
// content lives in the cache between sessions so probing the folder would
// reinstall everything
for info in &linked {
if let Some(pid) = &info.project_id {
linked_projects.insert(pid.as_str());
}
linked_hashes.insert(info.hash.as_str());
}
let present = PresentContent::load(cluster_id, ctx).await?;
let to_install: Vec<BundleFile> = archive
.manifest
@ -351,16 +412,8 @@ pub async fn install_enabled_bundle_files(
.iter()
.filter(|file| {
let package_id = file.kind.package_id();
if !effective_enabled(file, find_override(&overrides, &bundle_name, &package_id)) {
return false;
}
let already_installed = match &file.kind {
BundleFileKind::Managed { project_id, .. } => {
linked_projects.contains(project_id.as_str())
}
BundleFileKind::External(ext) => linked_hashes.contains(ext.sha1.as_str()),
};
!already_installed
effective_enabled(file, find_override(&overrides, &bundle_name, &package_id))
&& !present.contains(file)
})
.cloned()
.collect();
@ -425,6 +478,42 @@ pub async fn install_enabled_bundle_files(
Ok(installed)
}
struct PresentContent {
projects: std::collections::HashSet<String>,
hashes: std::collections::HashSet<String>,
tracked_ids: std::collections::HashSet<String>,
}
impl PresentContent {
async fn load(cluster_id: i64, ctx: &ContentCtx) -> ContentResult<Self> {
let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?;
let tracked_ids = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id)
.await?
.into_iter()
.filter_map(|row| row.package_id)
.collect();
Ok(Self {
projects: linked
.iter()
.filter_map(|info| info.project_id.clone())
.collect(),
hashes: linked.into_iter().map(|info| info.hash).collect(),
tracked_ids,
})
}
fn contains(&self, file: &BundleFile) -> bool {
match &file.kind {
BundleFileKind::Managed { project_id, .. } => self.projects.contains(project_id),
BundleFileKind::External { file: ext, id, .. } => {
self.hashes.contains(&ext.sha1)
|| id.as_ref().is_some_and(|id| self.tracked_ids.contains(id))
}
}
}
}
/// Kept modest each fetch also costs a provider API call rate limited per-minute
pub(crate) const BUNDLE_INSTALL_CONCURRENCY: usize = 6;
@ -445,32 +534,16 @@ pub async fn enabled_bundle_bytes(
.archives_for(ctx, &cluster.mc_version, loader)
.await?;
let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?;
let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?;
let mut linked_projects: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut linked_hashes: std::collections::HashSet<&str> = std::collections::HashSet::new();
for info in &linked {
if let Some(pid) = &info.project_id {
linked_projects.insert(pid.as_str());
}
linked_hashes.insert(info.hash.as_str());
}
let present = PresentContent::load(cluster_id, ctx).await?;
let mut total = 0u64;
for archive in &archives {
let bundle_name = &archive.manifest.name;
for file in &archive.manifest.files {
let package_id = file.kind.package_id();
if !effective_enabled(file, find_override(&overrides, bundle_name, &package_id)) {
continue;
}
let already_installed = match &file.kind {
BundleFileKind::Managed { project_id, .. } => {
linked_projects.contains(project_id.as_str())
}
BundleFileKind::External(ext) => linked_hashes.contains(ext.sha1.as_str()),
};
if already_installed {
if !effective_enabled(file, find_override(&overrides, bundle_name, &package_id))
|| present.contains(file)
{
continue;
}
total += file.size;
@ -832,13 +905,17 @@ mod tests {
hidden: false,
path: "mods/example.jar".to_string(),
size: 1,
kind: BundleFileKind::External(ExternalFile {
name: "example.jar".to_string(),
url: "https://example.invalid/example.jar".to_string(),
sha1: "abc123".to_string(),
size: 1,
content_type: ContentType::Mod,
}),
kind: BundleFileKind::External {
file: ExternalFile {
name: "example.jar".to_string(),
url: "https://example.invalid/example.jar".to_string(),
sha1: "abc123".to_string(),
size: 1,
content_type: ContentType::Mod,
},
id: None,
meta: None,
},
}
}

View file

@ -4,16 +4,17 @@ use std::str::FromStr;
use chrono::Utc;
use oneclient_db::dao::bundle as bundle_dao;
use oneclient_db::dao::package_metadata as meta_dao;
use oneclient_db::models::{BundleRow, NewBundle};
use tokio::sync::RwLock;
use crate::bundles::error::BundleError;
use crate::bundles::manifest::{BundleManifest as RemoteBundleManifest, RemoteBundleRef};
use crate::bundles::polymrpack;
use crate::bundles::types::BundleArchive;
use crate::bundles::types::{BundleArchive, BundleFileKind};
use crate::ctx::ContentCtx;
use crate::error::{ContentError, ContentResult};
use oneclient_common::domain::GameLoader;
use oneclient_common::domain::{GameLoader, ProviderId};
use oneclient_common::paths;
use oneclient_net::RequestError;
use oneclient_net::{EtagPolicy, fetch_cached};
@ -149,21 +150,23 @@ impl BundlesManager {
) -> ContentResult<Vec<BundleArchive>> {
let mut archives = Vec::new();
for bundle in self.list_for(ctx, mc_version, loader).await? {
let manifest = self.manifest_for_archive(&bundle.path).await?;
let manifest = self.manifest_for_archive(ctx, &bundle.path).await?;
archives.push(BundleArchive { bundle, manifest });
}
Ok(archives)
}
#[tracing::instrument(level = "debug", skip(self))]
#[tracing::instrument(level = "debug", skip(self, ctx))]
async fn manifest_for_archive(
&self,
ctx: &ContentCtx,
path: &Path,
) -> ContentResult<crate::bundles::types::BundleManifest> {
if let Some(manifest) = self.archive_cache.read().await.get(path) {
return Ok(manifest.clone());
}
let manifest = polymrpack::read_manifest_from_archive(path).await?;
record_external_metadata(&manifest, ctx).await;
self.archive_cache
.write()
.await
@ -270,6 +273,37 @@ impl Default for BundlesManager {
}
}
#[tracing::instrument(level = "debug", skip_all, fields(bundle = %manifest.name))]
async fn record_external_metadata(
manifest: &crate::bundles::types::BundleManifest,
ctx: &ContentCtx,
) {
for bundle_file in &manifest.files {
let BundleFileKind::External {
file,
meta: Some(meta),
..
} = &bundle_file.kind
else {
continue;
};
if let Err(err) = meta_dao::upsert_package_metadata(
&ctx.db,
ProviderId::Local as i64,
&file.sha1,
meta.name.as_deref().unwrap_or(&file.name),
meta.description.as_deref().unwrap_or_default(),
&meta.authors.join(", "),
meta.icon_url.as_deref(),
)
.await
{
tracing::warn!(file = %file.name, error = %err, "failed to record bundle file metadata");
}
}
}
struct FetchedManifest {
manifest: RemoteBundleManifest,
changed: bool,

View file

@ -6,7 +6,7 @@ use serde::Deserialize;
use crate::bundles::error::BundleError;
use crate::bundles::types::{
BundleFile, BundleFileKind, BundleManifest, content_type_from_bundle_path,
BundleFile, BundleFileKind, BundleManifest, ExternalFileMeta, content_type_from_bundle_path,
};
use crate::error::ContentResult;
use crate::packages::types::ExternalFile;
@ -49,6 +49,22 @@ struct PolyMrpackFile {
pub enabled: bool,
#[serde(default)]
pub hidden: bool,
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub overrides: Option<PolyMrpackFileOverrides>,
}
#[derive(Debug, Default, Deserialize)]
struct PolyMrpackFileOverrides {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub authors: Vec<String>,
#[serde(default)]
pub icon: Option<String>,
}
#[derive(Debug, Deserialize)]
@ -165,12 +181,45 @@ fn parse_bundle_file(file: &PolyMrpackFile) -> Option<BundleFile> {
hidden: file.hidden,
path: file.path.clone(),
size: file.file_size,
kind: BundleFileKind::External(ExternalFile {
name: file_name,
url: download_url,
sha1: file.hashes.sha1.to_ascii_lowercase(),
size: file.file_size,
content_type: content_type_from_bundle_path(&file.path),
}),
kind: BundleFileKind::External {
file: ExternalFile {
name: file_name,
url: download_url,
sha1: file.hashes.sha1.to_ascii_lowercase(),
size: file.file_size,
content_type: content_type_from_bundle_path(&file.path),
},
id: non_blank(file.id.as_deref()).map(|id| {
if id.starts_with(EXTERNAL_ID_PREFIX) {
id
} else {
format!("{EXTERNAL_ID_PREFIX}{id}")
}
}),
meta: file.overrides.as_ref().and_then(external_meta),
},
})
}
const EXTERNAL_ID_PREFIX: &str = "ext:";
fn non_blank(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn external_meta(overrides: &PolyMrpackFileOverrides) -> Option<ExternalFileMeta> {
let meta = ExternalFileMeta {
name: non_blank(overrides.name.as_deref()),
description: non_blank(overrides.description.as_deref()),
authors: overrides
.authors
.iter()
.filter_map(|author| non_blank(Some(author)))
.collect(),
icon_url: non_blank(overrides.icon.as_deref()),
};
(meta != ExternalFileMeta::default()).then_some(meta)
}

View file

@ -35,19 +35,39 @@ pub struct BundleFile {
impl BundleFile {
pub fn content_type(&self) -> ContentType {
if let BundleFileKind::External(ext) = &self.kind {
return ext.content_type;
if let BundleFileKind::External { file, .. } = &self.kind {
return file.content_type;
}
content_type_from_bundle_path(&self.path)
}
pub fn is_github_hosted(&self) -> bool {
let BundleFileKind::External { file, .. } = &self.kind else {
return false;
};
url::Url::parse(&file.url).is_ok_and(|url| {
url.host_str().is_some_and(|host| {
host == "github.com"
|| host.ends_with(".github.com")
|| host.ends_with(".githubusercontent.com")
})
})
}
pub fn display_name(&self) -> String {
if let BundleFileKind::External {
meta: Some(meta), ..
} = &self.kind
&& let Some(name) = &meta.name
{
return name.clone();
}
let from_path = self.path.rsplit('/').next().filter(|s| !s.is_empty());
if let Some(name) = from_path {
return name.to_string();
}
match &self.kind {
BundleFileKind::External(ext) => ext.name.clone(),
BundleFileKind::External { file, .. } => file.name.clone(),
BundleFileKind::Managed { project_id, .. } => project_id.clone(),
}
}
@ -61,21 +81,58 @@ pub enum BundleFileKind {
version_id: String,
sha1: String,
},
External(ExternalFile),
External {
file: ExternalFile,
id: Option<String>,
meta: Option<ExternalFileMeta>,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExternalFileMeta {
pub name: Option<String>,
pub description: Option<String>,
pub authors: Vec<String>,
pub icon_url: Option<String>,
}
impl BundleFileKind {
pub fn package_id(&self) -> String {
match self {
Self::Managed { project_id, .. } => project_id.clone(),
Self::External(ext) => ext.sha1.clone(),
Self::External { file, id, .. } => id.clone().unwrap_or_else(|| file.sha1.clone()),
}
}
pub fn bundle_version_id(&self) -> String {
match self {
Self::Managed { version_id, .. } => version_id.clone(),
Self::External(ext) => ext.sha1.clone(),
Self::External { file, .. } => file.sha1.clone(),
}
}
pub fn metadata_id(&self) -> String {
match self {
Self::Managed { project_id, .. } => project_id.clone(),
Self::External { file, .. } => file.sha1.clone(),
}
}
pub fn metadata_provider(&self) -> ProviderId {
match self {
Self::Managed { provider, .. } => *provider,
Self::External { .. } => ProviderId::Local,
}
}
pub fn bundle_key(&self) -> String {
match self {
Self::Managed {
provider,
project_id,
..
} => managed_bundle_key(*provider, project_id),
Self::External { .. } => external_bundle_key(&self.package_id()),
}
}
}
@ -84,8 +141,8 @@ pub fn managed_bundle_key(provider: ProviderId, package_id: &str) -> String {
format!("m:{}:{package_id}", provider.dir_name())
}
pub fn external_bundle_key(sha1: &str) -> String {
format!("e:{sha1}")
pub fn external_bundle_key(package_id: &str) -> String {
format!("e:{package_id}")
}
pub fn content_type_from_bundle_path(path: &str) -> ContentType {

View file

@ -17,9 +17,9 @@ use tokio::sync::Mutex as AsyncMutex;
use futures_util::StreamExt;
use crate::bundles::install::{
BUNDLE_INSTALL_CONCURRENCY, disable_was_deliberate, find_user_suppression,
heal_bundle_activity, install_package_from_bundle, remove_artifact_from_cluster,
set_artifact_enabled_to,
BUNDLE_INSTALL_CONCURRENCY, disable_was_deliberate, external_ids_by_sha1,
find_user_suppression, heal_bundle_activity, install_package_from_bundle,
remove_artifact_from_cluster, set_artifact_enabled_to,
};
use crate::bundles::manager::BundlesManager;
use crate::bundles::overrides;
@ -144,14 +144,7 @@ async fn check_bundle_updates_inner(
let mut shipped_keys = HashSet::new();
for file in &archive.manifest.files {
shipped_keys.insert(match &file.kind {
BundleFileKind::Managed {
provider,
project_id,
..
} => managed_bundle_key(*provider, project_id),
BundleFileKind::External(ext) => external_bundle_key(&ext.sha1),
});
shipped_keys.insert(file.kind.bundle_key());
let user_override = overrides_map
.get(&(archive.manifest.name.clone(), file.kind.package_id()))
@ -159,26 +152,10 @@ async fn check_bundle_updates_inner(
if !crate::bundles::effective_enabled(file, user_override) {
continue;
}
match &file.kind {
BundleFileKind::Managed {
provider,
project_id,
version_id,
..
} => {
let key = managed_bundle_key(*provider, project_id);
files_map.insert(key.clone(), (version_id.clone(), file.clone()));
if file.hidden {
hidden_explicit_keys.insert(key);
}
}
BundleFileKind::External(ext) => {
let key = external_bundle_key(&ext.sha1);
files_map.insert(key.clone(), (ext.sha1.clone(), file.clone()));
if file.hidden {
hidden_explicit_keys.insert(key);
}
}
let key = file.kind.bundle_key();
files_map.insert(key.clone(), (file.kind.bundle_version_id(), file.clone()));
if file.hidden {
hidden_explicit_keys.insert(key);
}
}
@ -230,6 +207,7 @@ async fn check_bundle_updates_inner(
.map(|a| a.manifest.name.as_str())
.collect();
let external_ids = external_ids_by_sha1(&archives);
let mut updates_available = Vec::new();
let mut removals_available = Vec::new();
@ -241,7 +219,8 @@ async fn check_bundle_updates_inner(
continue;
};
let installed_key = bundle_package_key(bundle_pkg, &all_linked_by_hash, pkg_id);
let installed_key =
bundle_package_key(bundle_pkg, &all_linked_by_hash, &external_ids, pkg_id);
let Some(bundle_name) = &bundle_pkg.bundle_name else {
continue;
@ -343,8 +322,15 @@ async fn check_bundle_updates_inner(
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();
for hash in all_installed_external_hashes {
planned_addition_keys.insert(external_bundle_key(&hash));
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));
}
}
for archive in &archives {
@ -358,15 +344,13 @@ async fn check_bundle_updates_inner(
.get(&(archive.manifest.name.clone(), file_id.clone()))
.copied();
let file_key = match &file.kind {
BundleFileKind::Managed {
provider,
project_id,
..
} => managed_bundle_key(*provider, project_id),
BundleFileKind::External(ext) => external_bundle_key(&ext.sha1),
};
if planned_addition_keys.contains(&file_key) {
let file_key = file.kind.bundle_key();
let exact_file_installed = matches!(
&file.kind,
BundleFileKind::External { file: ext, .. }
if planned_addition_keys.contains(&external_bundle_key(&ext.sha1))
);
if planned_addition_keys.contains(&file_key) || exact_file_installed {
continue;
}
@ -819,12 +803,17 @@ pub async fn get_bundles_with_update_status(
let all_linked_by_hash: HashMap<String, &LinkedArtifactInfo> =
all_linked.iter().map(|a| (a.hash.clone(), a)).collect();
let archives = bundles
.archives_for(ctx, &cluster.mc_version, loader)
.await?;
let external_ids = external_ids_by_sha1(&archives);
let mut installed_map: HashMap<String, &BundleTrackedArtifactRow> = HashMap::new();
for bundle_pkg in &bundle_packages {
let Some(pkg_id) = &bundle_pkg.package_id else {
continue;
};
let key = bundle_package_key(bundle_pkg, &all_linked_by_hash, pkg_id);
let key = bundle_package_key(bundle_pkg, &all_linked_by_hash, &external_ids, pkg_id);
installed_map.insert(key, bundle_pkg);
}
@ -837,9 +826,6 @@ pub async fn get_bundles_with_update_status(
})
.collect();
let archives = bundles
.archives_for(ctx, &cluster.mc_version, loader)
.await?;
let mut results = Vec::new();
for archive in archives {
@ -847,48 +833,25 @@ pub async fn get_bundles_with_update_status(
let mut has_updates = false;
for file in &archive.manifest.files {
let status = match &file.kind {
BundleFileKind::Managed {
provider,
project_id,
version_id,
..
} => {
let key = managed_bundle_key(*provider, project_id);
if let Some(installed) = installed_map.get(&key) {
let installed_version =
installed.bundle_version_id.as_deref().unwrap_or("");
if installed_version == version_id.as_str() {
FileUpdateStatus::UpToDate
} else {
has_updates = true;
FileUpdateStatus::UpdateAvailable {
installed_version_id: installed_version.to_string(),
new_version_id: version_id.clone(),
}
}
} else if matches!(
overrides_map.get(&(archive.manifest.name.clone(), project_id.clone())),
Some(OverrideType::Removed)
) {
FileUpdateStatus::RemovedByUser
} else {
FileUpdateStatus::NotInstalled
}
}
BundleFileKind::External(ext) => {
let key = external_bundle_key(&ext.sha1);
if installed_map.contains_key(&key) {
FileUpdateStatus::UpToDate
} else if matches!(
overrides_map.get(&(archive.manifest.name.clone(), ext.sha1.clone())),
Some(OverrideType::Removed)
) {
FileUpdateStatus::RemovedByUser
} else {
FileUpdateStatus::NotInstalled
let new_version_id = file.kind.bundle_version_id();
let status = if let Some(installed) = installed_map.get(&file.kind.bundle_key()) {
let installed_version = installed.bundle_version_id.as_deref().unwrap_or("");
if installed_version == new_version_id {
FileUpdateStatus::UpToDate
} else {
has_updates = true;
FileUpdateStatus::UpdateAvailable {
installed_version_id: installed_version.to_string(),
new_version_id,
}
}
} else if matches!(
overrides_map.get(&(archive.manifest.name.clone(), file.kind.package_id())),
Some(OverrideType::Removed)
) {
FileUpdateStatus::RemovedByUser
} else {
FileUpdateStatus::NotInstalled
};
files.push((file.clone(), status));
}
@ -906,14 +869,21 @@ pub async fn get_bundles_with_update_status(
fn bundle_package_key(
bundle_pkg: &BundleTrackedArtifactRow,
linked: &HashMap<String, &LinkedArtifactInfo>,
external_ids: &HashMap<String, String>,
package_id: &str,
) -> String {
if package_id == bundle_pkg.hash {
external_bundle_key(&bundle_pkg.hash)
} else if let Some(info) = linked.get(&bundle_pkg.hash) {
managed_bundle_key(info.provider.unwrap_or(ProviderId::Modrinth), package_id)
return external_bundle_key(
external_ids
.get(package_id)
.map_or(package_id, String::as_str),
);
}
let provider = linked.get(&bundle_pkg.hash).and_then(|info| info.provider);
if provider.is_none() && external_ids.values().any(|id| id == package_id) {
external_bundle_key(package_id)
} else {
managed_bundle_key(ProviderId::Modrinth, package_id)
managed_bundle_key(provider.unwrap_or(ProviderId::Modrinth), package_id)
}
}