From f94ca969b0926476f5b00b952dec3d1910588420 Mon Sep 17 00:00:00 2001 From: Maciek <108873945+rozwader@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:40:00 +0200 Subject: [PATCH] feat: bad mods flagging (#882) * feat: first version of flagging mods thru datastorage * feat: added fallbacks, version-specific flag and project-specific flag * chore: downsized the amount of requests sent when looking up for alternatives * feat: added explanations for flagging mods * fix: prompt for flagged dependencies instead of silently skipping them * chore: size the flagged popup with max_height instead of measuring it * chore: drop is_flagged and the explanation size cap * feat: refresh the bad mods list with the pre-launch checks --------- Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com> --- .../src/components/flagged_install_popup.rs | 282 +++++++ .../src/components/link_confirm.rs | 3 +- packages/oneclient_app/src/components/mod.rs | 6 +- .../src/components/package_row.rs | 11 +- packages/oneclient_app/src/hooks/actions.rs | 70 +- packages/oneclient_app/src/install.rs | 68 +- .../oneclient_app/src/layout/root_layout.rs | 8 +- packages/oneclient_app/src/state.rs | 13 + .../src/view/app/browser/index/mod.rs | 2 +- .../oneclient_app/src/view/app/browser/mod.rs | 1 + .../src/packages/bad_mods.rs | 701 ++++++++++++++++++ .../oneclient_content/src/packages/mod.rs | 5 + packages/oneclient_core/src/state.rs | 3 +- 13 files changed, 1156 insertions(+), 17 deletions(-) create mode 100644 packages/oneclient_app/src/components/flagged_install_popup.rs create mode 100644 packages/oneclient_content/src/packages/bad_mods.rs diff --git a/packages/oneclient_app/src/components/flagged_install_popup.rs b/packages/oneclient_app/src/components/flagged_install_popup.rs new file mode 100644 index 00000000..38f85005 --- /dev/null +++ b/packages/oneclient_app/src/components/flagged_install_popup.rs @@ -0,0 +1,282 @@ +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_content::packages::ResolvedAlternative; + +use crate::components::{ + Button, Icon, IconType, Markdown, MarkdownStyle, OverlayPopup, remote_icon, +}; +use crate::hooks::{use_cached_image, use_dispatch, use_installs_snapshot}; +use crate::routes::Route; +use crate::state::FlaggedInstallPrompt; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::app::browser::encode_package_id; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 460.; +const ROW_H: f32 = 48.; +const ICON_SIZE: f32 = 32.; +const ROW_GAP: f32 = 6.; +const SECTION_GAP: f32 = 14.; +const CONTENT_MAX_H: f32 = 400.; +const DIALOG_PADDING: f32 = 20.; + +#[derive(PartialEq)] +pub struct FlaggedInstallPopup; + +impl Component for FlaggedInstallPopup { + fn render(&self) -> impl IntoElement { + let installs = use_installs_snapshot(); + let dispatch = use_dispatch(); + + let Some(prompt) = installs.flagged.clone() else { + return rect().into_element(); + }; + + let close = dispatch.clone(); + + OverlayPopup::new() + .on_close(move |_| close.dismiss_flagged_install()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(dialog(prompt, dispatch)), + ) + .into_element() + } +} + +fn dialog(prompt: FlaggedInstallPrompt, dispatch: crate::Actions) -> impl IntoElement { + let description = if prompt.alternatives.is_empty() { + format!( + "{} is flagged as a problematic mod. Installing it is not recommended.", + prompt.name + ) + } else { + format!( + "{} is flagged as a problematic mod. Installing it is not recommended, consider one of these alternatives instead.", + prompt.name + ) + }; + + let mut body = rect() + .vertical() + .width(Size::px(DIALOG_W)) + .height(Size::Inner) + .max_width(Size::window_percent(90.)) + .max_height(Size::window_percent(85.)) + .overflow(Overflow::Clip) + .spacing(SECTION_GAP) + .padding(Gaps::new_all(DIALOG_PADDING)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(SECTION_GAP) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + Icon::new(IconType::AlertTriangle) + .size(20.) + .color(colors::code_warn()), + ) + .child( + label() + .text("Flagged mod") + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(description) + .font_size(12.) + .width(Size::fill()) + .color(colors::fg_secondary()), + ), + ); + + if prompt.explanation.is_some() || !prompt.alternatives.is_empty() { + body = body.child(flagged_content(&prompt)); + } + + let cancel = dispatch.clone(); + let anyway = dispatch; + + body.child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| cancel.dismiss_flagged_install()) + .text("Cancel"), + ) + .child( + Button::new() + .danger() + .on_press(move |_| anyway.install_flagged_anyway(prompt.clone())) + .text("Install anyway"), + ), + ) +} + +fn flagged_content(prompt: &FlaggedInstallPrompt) -> impl IntoElement { + let mut content = rect().vertical().width(Size::fill()).spacing(SECTION_GAP); + + if let Some(markdown) = &prompt.explanation { + content = content.child(explanation_panel(markdown.clone())); + } + + if !prompt.alternatives.is_empty() { + content = content.child( + rect() + .vertical() + .width(Size::fill()) + .spacing(ROW_GAP) + .children(prompt.alternatives.iter().map(|alternative| { + AlternativeRow { + alternative: alternative.clone(), + cluster_id: prompt.cluster_id, + mc_version: prompt.mc_version.clone(), + } + .into_element() + })), + ); + } + + ScrollView::new() + .width(Size::fill()) + .height(Size::Inner) + .max_height(Size::px(CONTENT_MAX_H)) + .child(content) +} + +#[derive(PartialEq)] +struct AlternativeRow { + alternative: ResolvedAlternative, + cluster_id: i64, + mc_version: String, +} + +impl Component for AlternativeRow { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let icon_query = use_cached_image(self.alternative.icon_url.clone(), 256); + let icon = remote_icon(self.alternative.icon_url.as_deref(), &icon_query, ICON_SIZE); + let version = self + .alternative + .version_number + .clone() + .unwrap_or_else(|| format!("No version for {}", self.mc_version)); + alternative_row( + self.alternative.clone(), + version, + self.cluster_id, + icon, + dispatch, + ) + } +} + +fn alternative_row( + alternative: ResolvedAlternative, + version: String, + cluster_id: i64, + icon: Element, + dispatch: crate::Actions, +) -> impl IntoElement { + let ResolvedAlternative { + provider, + project_id, + name, + .. + } = alternative; + + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .height(Size::px(ROW_H)) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_symmetric(0., 8.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.)) + .spacing(2.) + .child( + label() + .text(name) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(version) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) + .child( + Button::new() + .primary() + .small() + .on_press(move |_| { + dispatch.dismiss_flagged_install(); + let _ = RouterContext::get().push(Route::BrowserPackage { + cluster_id, + package_type: "mod".to_string(), + package_id: encode_package_id(provider, &project_id), + }); + }) + .child(Icon::new(IconType::ArrowRight).size(14.)) + .text("Go to page"), + ) +} + +fn explanation_panel(markdown: String) -> impl IntoElement { + rect() + .width(Size::fill()) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + Markdown::new(markdown) + .width(Size::fill()) + .style(MarkdownStyle { + color: colors::fg_primary(), + color_link: colors::code_info(), + color_code: colors::fg_primary(), + background_code: CARD_BG, + background_blockquote: CARD_BG, + border_blockquote: colors::component_border(), + background_divider: colors::component_border(), + headings: [16., 14., 13., 12., 12., 12.], + paragraph_size: 12., + code_font_size: 11., + ..MarkdownStyle::default() + }), + ) +} diff --git a/packages/oneclient_app/src/components/link_confirm.rs b/packages/oneclient_app/src/components/link_confirm.rs index df537a08..eb5d64b6 100644 --- a/packages/oneclient_app/src/components/link_confirm.rs +++ b/packages/oneclient_app/src/components/link_confirm.rs @@ -1,6 +1,6 @@ use freya::prelude::*; -use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::components::{Button, Icon, IconType, OVERLAY_BASE_LEVEL, OverlayPopup}; use crate::hooks::use_link_confirm; use crate::theme::colors; use crate::ui::border_all_color; @@ -20,6 +20,7 @@ impl Component for ConfirmLinkOverlay { let open_url = url.clone(); OverlayPopup::new() + .overlay_level(OVERLAY_BASE_LEVEL + 3) .on_close(move |_| pending.set(None)) .child( rect() diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index fffaa212..79b66f95 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -12,6 +12,7 @@ mod date_range_picker; mod dropdown; mod dynamic_art; mod file_drop; +mod flagged_install_popup; mod generic_prompt; mod icons; mod java_install_manager; @@ -62,6 +63,7 @@ pub use date_range_picker::{DateRange, DateRangePicker}; pub use dropdown::Dropdown; pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt}; pub use file_drop::FileDropOverlay; +pub use flagged_install_popup::FlaggedInstallPopup; pub use generic_prompt::GenericPromptOverlay; pub use icons::{Icon, IconTint, IconType}; pub use java_install_manager::JavaInstallManager; @@ -80,7 +82,9 @@ pub use notifications::NotificationCenter; 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(crate) use package_row::{ + CARD_GRID_H, GRID_GAP, GRID_MIN_W, grid_card, package_icon, remote_icon, +}; pub use package_row::{ CardLayout, PackageEntry, PackageRow, github_badge, package_context_menu, provider_badge, }; diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index 0aed716f..ffcc83ca 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -659,8 +659,15 @@ pub(crate) fn package_icon( icon_query: &freya::query::UseQuery, size: f32, ) -> Element { - let icon_url = &item.icon_url; - let loaded = loaded_image(icon_url.as_deref(), icon_query); + remote_icon(item.icon_url.as_deref(), icon_query, size) +} + +pub(crate) fn remote_icon( + icon_url: Option<&str>, + icon_query: &freya::query::UseQuery, + size: f32, +) -> Element { + let loaded = loaded_image(icon_url, icon_query); match loaded { Some((url, bytes)) => ImageViewer::new((url, bytes)) diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index 45e10bad..ce9be361 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -28,7 +28,7 @@ use crate::notifications::{ ClusterUpdateSummary, NotificationAction, NotificationSpec, OptionalModRef, OptionalModsGroup, OptionalModsOutcome, PackageUpdateGroup, PendingPrompt, }; -use crate::state::{AppChannel, AppState, AsyncStatus, RelocationState}; +use crate::state::{AppChannel, AppState, AsyncStatus, FlaggedInstallPrompt, RelocationState}; use crate::{invalidate_java_queries, launcher}; /// Over-disabling is the cheaper mistake a dead button beats a second game @@ -1064,7 +1064,42 @@ impl Actions { project_id: impl Into, version_id: impl Into, ) { - let (project_id, version_id) = (project_id.into(), version_id.into()); + self.start_install( + cluster_id, + provider, + project_id.into(), + version_id.into(), + false, + ); + } + + pub fn install_flagged_anyway(&self, prompt: FlaggedInstallPrompt) { + self.dismiss_flagged_install(); + self.start_install( + prompt.cluster_id, + prompt.provider, + prompt.project_id, + prompt.version_id, + true, + ); + } + + pub fn dismiss_flagged_install(&self) { + self.station + .clone() + .write_channel(AppChannel::Installs) + .installs + .flagged = None; + } + + fn start_install( + &self, + cluster_id: ClusterId, + provider: ProviderId, + project_id: String, + version_id: String, + allow_flagged: bool, + ) { let actions = self.clone(); self.station @@ -1088,14 +1123,34 @@ impl Actions { return; }; - let install = crate::install::install_package( + let install = match crate::install::install_package( &state, provider, &project_id, &version_id, cluster_id, + allow_flagged, ) - .await; + .await + { + Ok(install) => install, + Err(flagged) => { + let mut station = actions.station; + let mut app = station.write_channel(AppChannel::Installs); + app.installs.flagged = Some(FlaggedInstallPrompt { + cluster_id, + provider, + project_id: project_id.clone(), + version_id: version_id.clone(), + name: flagged.name, + mc_version: flagged.mc_version, + explanation: flagged.explanation, + alternatives: flagged.alternatives, + }); + app.installs.finish(cluster_id, provider, &project_id); + return; + } + }; // Replaces the download's progress notification in place rather // than arriving as a second one @@ -1813,6 +1868,13 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) { crate::microsoft_java::offer_for_pinned_cluster(actions, cluster_id).await; + let content = state.services.content(); + tokio::spawn(async move { + if let Err(err) = oneclient_content::packages::refresh_bad_mods(&content).await { + tracing::warn!(%err, "bad mods list refresh failed, keeping the last one"); + } + }); + // Before the game process never after Minecraft reads its mods once at // startup actions diff --git a/packages/oneclient_app/src/install.rs b/packages/oneclient_app/src/install.rs index 4355b548..a70bf606 100644 --- a/packages/oneclient_app/src/install.rs +++ b/packages/oneclient_app/src/install.rs @@ -207,6 +207,13 @@ pub struct PackageInstall { pub live_deferred: bool, } +pub struct FlaggedInstall { + pub name: String, + pub mc_version: String, + pub explanation: Option, + pub alternatives: Vec, +} + pub fn install_body( name: &str, dependencies: &[String], @@ -247,13 +254,46 @@ impl PackageInstall { } } +async fn flagged_install( + name: String, + entry: &oneclient_content::packages::BadMod, + cluster_id: i64, + content: &oneclient_content::ContentCtx, +) -> FlaggedInstall { + let resolve = async { + match PackageStore::get_cluster(cluster_id, content).await { + Ok(cluster) => { + let alternatives = + oneclient_content::packages::resolve_alternatives(entry, &cluster, content) + .await; + (cluster.mc_version, alternatives) + } + Err(err) => { + tracing::warn!(%err, cluster_id, "cannot resolve alternatives without the cluster"); + (String::new(), Vec::new()) + } + } + }; + let ((mc_version, alternatives), explanation) = tokio::join!( + resolve, + oneclient_content::packages::fetch_explanation(entry, content), + ); + FlaggedInstall { + name, + mc_version, + explanation, + alternatives, + } +} + pub async fn install_package( state: &Arc, provider: oneclient_content::packages::ProviderId, project_id: &str, version_id: &str, cluster_id: i64, -) -> PackageInstall { + allow_flagged: bool, +) -> Result { let lookup = async { let provider_impl = state.services.packages.get(provider)?; let project = provider_impl @@ -268,9 +308,17 @@ pub async fn install_package( let (project, version) = match lookup { Ok(found) => found, - Err(err) => return PackageInstall::failed(err), + Err(err) => return Ok(PackageInstall::failed(err)), }; + let content = state.services.content(); + let bad_mods = oneclient_content::packages::load_bad_mods(&content).await; + + if !allow_flagged && let Some(entry) = bad_mods.check(&project, &version) { + tracing::warn!(project = %project.name, version = %version.version_number, "refusing to install flagged mod"); + return Err(flagged_install(project.name, entry, cluster_id, &content).await); + } + // Resolved before the session starts so its children can be announced up front let mut resolution = oneclient_content::packages::DependencyResolution::default(); if oneclient_content::packages::resolves_dependencies(project.content_type) { @@ -289,6 +337,18 @@ pub async fn install_package( } } + if !allow_flagged + && let Some((dependency, entry)) = resolution.install.iter().find_map(|dependency| { + bad_mods + .check(&dependency.project, &dependency.version) + .map(|entry| (dependency, entry)) + }) + { + tracing::warn!(dependency = %dependency.project.name, project = %project.name, "refusing to install flagged dependency"); + let name = format!("{} (required by {})", dependency.project.name, project.name); + return Err(flagged_install(name, entry, cluster_id, &content).await); + } + let session = oneclient_events::GroupedProgressSession::start( &state.services.events, format!("Installing {}", project.name), @@ -380,13 +440,13 @@ pub async fn install_package( mark_new(cluster_id, &artifact.hash, state).await; } - PackageInstall { + Ok(PackageInstall { session_id: Some(session.detach()), result: result.map(|_| project.name).map_err(anyhow::Error::from), dependencies: installed_dependencies, missing_dependencies, live_deferred, - } + }) } async fn mark_new(cluster_id: i64, hash: &str, state: &LauncherState) { diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 011d1101..96739117 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -2,9 +2,10 @@ use freya::prelude::*; use freya::router::*; use crate::components::{ - AccountSwitcher, ClusterUpdatePopup, ControlCenter, GenericPromptOverlay, JavaPromptOverlay, - MicrosoftJavaPromptOverlay, NotificationCenter, OptionalModsPopup, PackageUpdatePopup, - SplashCurtain, StatusBar, Toasts, TooltipHost, UpdatePromptOverlay, use_provide_tooltips, + AccountSwitcher, ClusterUpdatePopup, ControlCenter, FlaggedInstallPopup, GenericPromptOverlay, + JavaPromptOverlay, MicrosoftJavaPromptOverlay, NotificationCenter, OptionalModsPopup, + PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, TooltipHost, UpdatePromptOverlay, + use_provide_tooltips, }; #[cfg(not(target_os = "macos"))] use crate::hooks::use_start_maximized; @@ -52,6 +53,7 @@ impl Component for RootLayout { .child(ClusterUpdatePopup) .child(OptionalModsPopup) .child(PackageUpdatePopup) + .child(FlaggedInstallPopup) .child(StatusBar) .child(TooltipHost) .child(SplashCurtain) diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs index 575e1dc3..a34292f7 100644 --- a/packages/oneclient_app/src/state.rs +++ b/packages/oneclient_app/src/state.rs @@ -69,6 +69,19 @@ pub struct RelocationState { #[derive(Clone, Debug, Default, PartialEq)] pub struct InstallState { pending: HashSet<(i64, ProviderId, String)>, + pub flagged: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FlaggedInstallPrompt { + pub cluster_id: i64, + pub provider: ProviderId, + pub project_id: String, + pub version_id: String, + pub name: String, + pub mc_version: String, + pub explanation: Option, + pub alternatives: Vec, } impl InstallState { diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index d5996990..bc485053 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -76,7 +76,7 @@ fn type_title(package_type: &str) -> &'static str { .map_or("Mods", |(_, title)| *title) } -fn encode_package_id(provider: ProviderId, id: &str) -> String { +pub(crate) fn encode_package_id(provider: ProviderId, id: &str) -> String { format!("{}:{}", provider as u8, id) } diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index aedc8432..31d4dcc4 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -2,6 +2,7 @@ mod index; mod package; pub use index::Browser; +pub(crate) use index::encode_package_id; pub use package::BrowserPackage; use std::collections::HashMap; diff --git a/packages/oneclient_content/src/packages/bad_mods.rs b/packages/oneclient_content/src/packages/bad_mods.rs new file mode 100644 index 00000000..20d489ee --- /dev/null +++ b/packages/oneclient_content/src/packages/bad_mods.rs @@ -0,0 +1,701 @@ +use std::collections::HashSet; +use std::sync::{Arc, PoisonError, RwLock}; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Deserializer}; + +use oneclient_common::paths; +use oneclient_db::models::ClusterRow; +use oneclient_net::{EtagPolicy, fetch_cached}; + +use super::dependencies::pick_version; +use super::{ContentType, FileIdentity, ProjectDetail, ProviderId, VersionDetail, VersionLookup}; +use crate::ctx::ContentCtx; +use crate::error::{ContentError, ContentResult}; + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct BadModList { + #[serde(rename = "bad-mods", default, deserialize_with = "lenient_list")] + pub bad_mods: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct BadMod { + #[serde(default, deserialize_with = "non_blank")] + pub hash: Option, + #[serde(default)] + pub project_ids: ProjectIds, + #[serde(default, deserialize_with = "non_blank")] + pub name: Option, + #[serde(default, deserialize_with = "non_blank")] + pub author: Option, + #[serde(default, deserialize_with = "non_blank")] + pub explanation: Option, + #[serde(default, deserialize_with = "lenient_list")] + pub alternatives: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct BadModAlternative { + #[serde(default, deserialize_with = "non_blank")] + pub hash: Option, + #[serde(default)] + pub project_ids: ProjectIds, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +pub struct ProjectIds { + #[serde(default, deserialize_with = "project_id")] + pub modrinth: Option, + #[serde(default, deserialize_with = "project_id")] + pub curseforge: Option, +} + +impl ProjectIds { + pub fn get(&self, provider: ProviderId) -> Option<&str> { + match provider { + ProviderId::Modrinth => self.modrinth.as_deref(), + ProviderId::CurseForge => self.curseforge.as_deref(), + ProviderId::Local => None, + } + } + + pub fn is_empty(&self) -> bool { + self.modrinth.is_none() && self.curseforge.is_none() + } +} + +fn lenient_list<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: DeserializeOwned, +{ + let raw = Option::>::deserialize(deserializer)?.unwrap_or_default(); + + Ok(raw + .into_iter() + .enumerate() + .filter_map(|(index, value)| match serde_json::from_value(value) { + Ok(item) => Some(item), + Err(err) => { + tracing::warn!(index, %err, "skipping malformed bad mods entry"); + None + } + }) + .collect()) +} + +fn blank_to_none(value: String) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +fn non_blank<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + Ok(Option::::deserialize(deserializer)?.and_then(blank_to_none)) +} + +fn project_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum RawId { + Text(String), + Number(u64), + } + + Ok(match Option::::deserialize(deserializer)? { + Some(RawId::Text(id)) => blank_to_none(id), + Some(RawId::Number(id)) => Some(id.to_string()), + None => None, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedAlternative { + pub provider: ProviderId, + pub project_id: String, + pub name: String, + pub version_number: Option, + pub icon_url: Option, +} + +impl BadModList { + pub fn check(&self, project: &ProjectDetail, version: &VersionDetail) -> Option<&BadMod> { + if project.content_type != ContentType::Mod { + return None; + } + + let authors: Vec<&str> = std::iter::once(project.author.as_str()) + .chain(project.members.iter().map(|member| member.name.as_str())) + .collect(); + + version + .primary_file() + .and_then(|file| self.find(&file.sha1)) + .or_else(|| self.find_project(project.provider, &project.id)) + .or_else(|| self.find_name_and_author(&project.name, &authors)) + } + + pub fn find(&self, sha1: &str) -> Option<&BadMod> { + let sha1 = polyio::normalize_hash(sha1); + self.bad_mods.iter().find(|entry| { + entry + .hash + .as_deref() + .is_some_and(|hash| polyio::normalize_hash(hash) == sha1) + }) + } + + fn find_project(&self, provider: ProviderId, project_id: &str) -> Option<&BadMod> { + self.bad_mods + .iter() + .find(|entry| entry.project_ids.get(provider) == Some(project_id)) + } + + fn find_name_and_author(&self, name: &str, authors: &[&str]) -> Option<&BadMod> { + self.bad_mods.iter().find(|entry| { + if entry.hash.is_some() || !entry.project_ids.is_empty() { + return false; + } + let (Some(entry_name), Some(entry_author)) = + (entry.name.as_deref(), entry.author.as_deref()) + else { + return false; + }; + same_text(entry_name, name) + && authors.iter().any(|author| same_text(entry_author, author)) + }) + } +} + +fn same_text(a: &str, b: &str) -> bool { + a.trim().to_lowercase() == b.trim().to_lowercase() +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn fetch_bad_mods(ctx: &ContentCtx) -> ContentResult { + let url = format!("{}/oneclient/bad-mods.json", ctx.net.config().meta_url_base); + let cache_path = paths::caches_dir()?.join("bad-mods.json"); + + let Some(fetched) = fetch_cached(&ctx.net, &url, &cache_path, EtagPolicy::CommitNow).await? + else { + return Err(ContentError::InvalidData { + reason: "bad mods list is unavailable and not cached".to_string(), + }); + }; + + Ok(fetched.json()?) +} + +static BAD_MODS: RwLock>> = RwLock::new(None); + +pub async fn refresh_bad_mods(ctx: &ContentCtx) -> ContentResult> { + let list = Arc::new(fetch_bad_mods(ctx).await?); + *BAD_MODS.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::clone(&list)); + Ok(list) +} + +pub async fn load_bad_mods(ctx: &ContentCtx) -> Arc { + let loaded = BAD_MODS + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + if let Some(list) = loaded { + return list; + } + + refresh_bad_mods(ctx).await.unwrap_or_else(|err| { + tracing::warn!(%err, "bad mods list unavailable, installs are not screened"); + Arc::default() + }) +} + +const EXPLANATIONS_DIR: &str = "/oneclient/bad_mods_mds/"; + +fn explanation_file_name(path: &str) -> Option<&str> { + let name = path.strip_prefix(EXPLANATIONS_DIR)?; + let valid = name.ends_with(".md") + && name.len() > ".md".len() + && !name.contains("..") + && !name.contains(['/', '\\', '?', '#']); + valid.then_some(name) +} + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn fetch_explanation(entry: &BadMod, ctx: &ContentCtx) -> Option { + let path = entry.explanation.as_deref()?; + let Some(file_name) = explanation_file_name(path) else { + tracing::warn!( + path, + "ignoring bad mod explanation outside {EXPLANATIONS_DIR}" + ); + return None; + }; + + let url = format!("{}{path}", ctx.net.config().meta_url_base); + let cache_path = match paths::caches_dir() { + Ok(dir) => dir.join("bad_mods_mds").join(file_name), + Err(err) => { + tracing::warn!(%err, "cannot resolve the cache dir for bad mod explanations"); + return None; + } + }; + + match fetch_cached(&ctx.net, &url, &cache_path, EtagPolicy::CommitNow).await { + Ok(Some(fetched)) => Some(fetched.text()).filter(|text| !text.trim().is_empty()), + Ok(None) => { + tracing::warn!(path, "bad mod explanation is unavailable and not cached"); + None + } + Err(err) => { + tracing::warn!(%err, path, "failed to fetch bad mod explanation"); + None + } + } +} + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn resolve_alternatives( + entry: &BadMod, + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> Vec { + let by_hash = lookup_alternative_hashes(entry, ctx).await; + + let hits: Vec<(&BadModAlternative, Option<&VersionDetail>)> = entry + .alternatives + .iter() + .map(|alternative| { + let hit = alternative + .hash + .as_deref() + .and_then(|hash| by_hash.get(&polyio::normalize_hash(hash))); + (alternative, hit) + }) + .collect(); + + let mut modrinth_ids = HashSet::new(); + let mut curseforge_ids = HashSet::new(); + for (alternative, hit) in &hits { + match hit { + Some(version) => { + modrinth_ids.insert(version.project_id.clone()); + } + None => { + if let Some(id) = alternative.project_ids.modrinth.as_deref() { + if is_modrinth_id(id) { + modrinth_ids.insert(id.to_string()); + } else { + tracing::warn!(id, "ignoring malformed Modrinth project id"); + } + } + if let Some(id) = alternative.project_ids.curseforge.as_deref() { + curseforge_ids.insert(id.to_string()); + } + } + } + } + + let (modrinth, curseforge) = tokio::join!( + fetch_projects(ProviderId::Modrinth, modrinth_ids, ctx), + fetch_projects(ProviderId::CurseForge, curseforge_ids, ctx), + ); + + let resolved = futures_util::future::join_all(hits.into_iter().map(|(alternative, hit)| { + resolve_alternative(alternative, hit, &modrinth, &curseforge, cluster, ctx) + })) + .await; + + let mut seen = HashSet::new(); + resolved + .into_iter() + .flatten() + .filter(|alternative| seen.insert((alternative.provider, alternative.project_id.clone()))) + .collect() +} + +async fn lookup_alternative_hashes(entry: &BadMod, ctx: &ContentCtx) -> VersionLookup { + let mut seen = HashSet::new(); + let identities: Vec = entry + .alternatives + .iter() + .filter_map(|alt| alt.hash.as_deref()) + .map(FileIdentity::from_sha1) + .filter(|identity| seen.insert(identity.sha1.clone())) + .collect(); + + if identities.is_empty() { + return VersionLookup::new(); + } + + let lookup = match ctx.providers.get(ProviderId::Modrinth) { + Ok(provider) => provider.lookup_versions(&identities, ctx).await, + Err(err) => Err(err), + }; + + lookup.unwrap_or_else(|err| { + tracing::warn!(%err, "failed to look up bad mod alternatives by hash"); + VersionLookup::new() + }) +} + +fn is_modrinth_id(id: &str) -> bool { + id.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) +} + +async fn fetch_projects( + provider_id: ProviderId, + ids: HashSet, + ctx: &ContentCtx, +) -> Vec { + if ids.is_empty() { + return Vec::new(); + } + + let ids: Vec = ids.into_iter().collect(); + let fetched = match ctx.providers.get(provider_id) { + Ok(provider) => provider.get_projects(&ids, ctx).await, + Err(err) => Err(err), + }; + + fetched.unwrap_or_else(|err| { + tracing::warn!(%err, ?provider_id, "failed to fetch alternative projects"); + Vec::new() + }) +} + +fn find_project<'a>(projects: &'a [ProjectDetail], id: &str) -> Option<&'a ProjectDetail> { + projects + .iter() + .find(|project| project.id == id || project.slug.eq_ignore_ascii_case(id)) +} + +async fn resolve_alternative( + alternative: &BadModAlternative, + hit: Option<&VersionDetail>, + modrinth: &[ProjectDetail], + curseforge: &[ProjectDetail], + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> Option { + if let Some(version) = hit { + let (name, icon_url) = find_project(modrinth, &version.project_id) + .map(|project| (project.name.clone(), project.icon_url.clone())) + .unwrap_or_else(|| (version.name.clone(), None)); + + return Some(ResolvedAlternative { + provider: ProviderId::Modrinth, + project_id: version.project_id.clone(), + name, + version_number: Some(version.version_number.clone()), + icon_url, + }); + } + + for (provider_id, projects) in [ + (ProviderId::Modrinth, modrinth), + (ProviderId::CurseForge, curseforge), + ] { + let Some(project) = alternative + .project_ids + .get(provider_id) + .and_then(|id| find_project(projects, id)) + else { + continue; + }; + return Some(with_newest_version(provider_id, project, cluster, ctx).await); + } + + tracing::warn!( + ?alternative, + "bad mod alternative not found by hash or project id" + ); + None +} + +async fn with_newest_version( + provider_id: ProviderId, + project: &ProjectDetail, + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> ResolvedAlternative { + let picked = match ctx.providers.get(provider_id) { + Ok(provider) => pick_version(provider, &project.id, cluster, ctx).await, + Err(err) => Err(err), + }; + + let version_number = match picked { + Ok(pick) => pick.map(|pick| pick.version_number), + Err(err) => { + tracing::warn!(%err, ?provider_id, project_id = %project.id, "failed to pick alternative version"); + None + } + }; + + ResolvedAlternative { + provider: provider_id, + project_id: project.id.clone(), + name: project.name.clone(), + version_number, + icon_url: project.icon_url.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FULL: &[u8] = br#"{ + "bad-mods": [ + { + "hash": "28b67391892582747bd17a7525318a954e54c80a", + "project-ids": { "modrinth": "1bokaNcj", "curseforge": "263420" }, + "name": "Xaero's Minimap", + "author": "xaero96", + "alternatives": [ + { + "hash": "d1741855c0e2b433f615c25e61c211b594dc525d", + "project-ids": { "modrinth": "HXF82T3G", "curseforge": "220318" } + }, + { + "hash": "04739b04815269648fb885526b2c2ff2266e0cc5", + "project-ids": { "modrinth": "OhduvhIc" } + } + ] + } + ] + }"#; + + #[test] + fn parses_full_entry() { + let list: BadModList = serde_json::from_slice(FULL).unwrap(); + let entry = &list.bad_mods[0]; + + assert_eq!( + entry.hash.as_deref(), + Some("28b67391892582747bd17a7525318a954e54c80a") + ); + assert_eq!( + entry.project_ids.get(ProviderId::Modrinth), + Some("1bokaNcj") + ); + assert_eq!( + entry.project_ids.get(ProviderId::CurseForge), + Some("263420") + ); + assert_eq!(entry.name.as_deref(), Some("Xaero's Minimap")); + assert_eq!(entry.author.as_deref(), Some("xaero96")); + + assert_eq!(entry.alternatives.len(), 2); + assert_eq!( + entry.alternatives[1].project_ids.get(ProviderId::Modrinth), + Some("OhduvhIc") + ); + assert_eq!(entry.alternatives[1].project_ids.curseforge, None); + } + + #[test] + fn every_field_is_optional() { + let list: BadModList = serde_json::from_slice(br#"{}"#).unwrap(); + assert!(list.bad_mods.is_empty()); + + let list: BadModList = serde_json::from_slice( + br#"{"bad-mods":[{"name":"X","author":"Y","alternatives":[{}]}]}"#, + ) + .unwrap(); + let entry = &list.bad_mods[0]; + assert_eq!(entry.hash, None); + assert!(entry.project_ids.is_empty()); + assert_eq!(entry.alternatives[0].hash, None); + assert!(entry.alternatives[0].project_ids.is_empty()); + } + + #[test] + fn blank_values_count_as_missing() { + let raw = br#"{"bad-mods":[{"hash":" ","name":"","project-ids":{"modrinth":" ","curseforge":""}}]}"#; + let list: BadModList = serde_json::from_slice(raw).unwrap(); + let entry = &list.bad_mods[0]; + + assert_eq!(entry.hash, None); + assert_eq!(entry.name, None); + assert!(entry.project_ids.is_empty()); + } + + #[test] + fn curseforge_id_accepts_a_number() { + let raw = br#"{"bad-mods":[{"project-ids":{"curseforge":263420}}]}"#; + let list: BadModList = serde_json::from_slice(raw).unwrap(); + + assert_eq!( + list.bad_mods[0].project_ids.get(ProviderId::CurseForge), + Some("263420") + ); + } + + #[test] + fn find_ignores_case_and_whitespace() { + let list: BadModList = + serde_json::from_slice(br#"{"bad-mods":[{"hash":" ABCdef "},{"name":"X"}]}"#).unwrap(); + + assert!(list.find("abcdef").is_some()); + assert!(list.find("ABCDEF").is_some()); + assert!(list.find("abcde0").is_none()); + } + + #[test] + fn project_id_matches_only_its_provider() { + let list: BadModList = serde_json::from_slice(FULL).unwrap(); + + assert!( + list.find_project(ProviderId::Modrinth, "1bokaNcj") + .is_some() + ); + assert!( + list.find_project(ProviderId::CurseForge, "263420") + .is_some() + ); + assert!( + list.find_project(ProviderId::CurseForge, "1bokaNcj") + .is_none() + ); + assert!(list.find_project(ProviderId::Modrinth, "other").is_none()); + } + + const NAME_ONLY: &[u8] = br#"{"bad-mods":[{"name":"Xaero's Minimap","author":"xaero96"}]}"#; + + #[test] + fn name_and_author_must_both_match() { + let list: BadModList = serde_json::from_slice(NAME_ONLY).unwrap(); + + assert!( + list.find_name_and_author(" xaero's minimap ", &["someone", "XAERO96"]) + .is_some() + ); + assert!( + list.find_name_and_author("Xaero's Minimap", &["someone"]) + .is_none() + ); + assert!( + list.find_name_and_author("Xaero's World Map", &["xaero96"]) + .is_none() + ); + } + + #[test] + fn name_and_author_ignored_when_hash_or_project_id_present() { + let list: BadModList = serde_json::from_slice(FULL).unwrap(); + assert!( + list.find_name_and_author("Xaero's Minimap", &["xaero96"]) + .is_none() + ); + + let with_hash: BadModList = serde_json::from_slice( + br#"{"bad-mods":[{"hash":"aaa","name":"Xaero's Minimap","author":"xaero96"}]}"#, + ) + .unwrap(); + assert!( + with_hash + .find_name_and_author("Xaero's Minimap", &["xaero96"]) + .is_none() + ); + + let with_id: BadModList = serde_json::from_slice( + br#"{"bad-mods":[{"project-ids":{"curseforge":"1"},"name":"Xaero's Minimap","author":"xaero96"}]}"#, + ) + .unwrap(); + assert!( + with_id + .find_name_and_author("Xaero's Minimap", &["xaero96"]) + .is_none() + ); + } + + #[test] + fn malformed_entries_are_skipped_not_fatal() { + let raw = br#"{"bad-mods":[ + {"hash":"aaa","alternatives":[{"hash":5},{"hash":"bbb"}]}, + {"hash":123}, + {"project-ids":"oops"}, + "not an object", + {"hash":"ccc"} + ]}"#; + let list: BadModList = serde_json::from_slice(raw).unwrap(); + + assert_eq!(list.bad_mods.len(), 2); + assert_eq!(list.bad_mods[0].alternatives.len(), 1); + assert_eq!( + list.bad_mods[0].alternatives[0].hash.as_deref(), + Some("bbb") + ); + assert!(list.find("ccc").is_some()); + } + + #[test] + fn null_list_is_empty() { + let list: BadModList = serde_json::from_slice(br#"{"bad-mods":null}"#).unwrap(); + assert!(list.bad_mods.is_empty()); + } + + #[test] + fn modrinth_ids_reject_url_breaking_characters() { + assert!(is_modrinth_id("1bokaNcj")); + assert!(is_modrinth_id("xaeros-minimap")); + assert!(!is_modrinth_id("bad id")); + assert!(!is_modrinth_id("a\"],[\"b")); + } + + #[test] + fn parses_explanation_path() { + let raw = br#"{"bad-mods":[{"hash":"aaa","explanation":"/oneclient/bad_mods_mds/test-file.md"},{"hash":"bbb","explanation":" "}]}"#; + let list: BadModList = serde_json::from_slice(raw).unwrap(); + + assert_eq!( + list.bad_mods[0].explanation.as_deref(), + Some("/oneclient/bad_mods_mds/test-file.md") + ); + assert_eq!(list.bad_mods[1].explanation, None); + } + + #[test] + fn explanation_must_be_an_md_file_in_the_folder() { + assert_eq!( + explanation_file_name("/oneclient/bad_mods_mds/test-file.md"), + Some("test-file.md") + ); + assert_eq!(explanation_file_name("/oneclient/tos.json"), None); + assert_eq!( + explanation_file_name("/oneclient/bad_mods_mds/notes.txt"), + None + ); + assert_eq!(explanation_file_name("/oneclient/bad_mods_mds/.md"), None); + assert_eq!( + explanation_file_name("/oneclient/bad_mods_mds/../tos.md"), + None + ); + assert_eq!( + explanation_file_name("/oneclient/bad_mods_mds/sub/file.md"), + None + ); + assert_eq!( + explanation_file_name("/oneclient/bad_mods_mds/a.md?x=1"), + None + ); + assert_eq!( + explanation_file_name("oneclient/bad_mods_mds/test-file.md"), + None + ); + assert_eq!( + explanation_file_name("https://evil.example/oneclient/bad_mods_mds/x.md"), + None + ); + } + + #[test] + fn name_without_author_never_matches() { + let list: BadModList = serde_json::from_slice(br#"{"bad-mods":[{"name":"X"}]}"#).unwrap(); + + assert!(list.find_name_and_author("X", &[""]).is_none()); + } +} diff --git a/packages/oneclient_content/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs index 12ab95fa..482d6cf2 100644 --- a/packages/oneclient_content/src/packages/mod.rs +++ b/packages/oneclient_content/src/packages/mod.rs @@ -1,4 +1,5 @@ pub mod activity; +pub mod bad_mods; pub mod dependencies; pub mod error; pub mod local_manifest; @@ -14,6 +15,10 @@ pub mod markdown; // Re-exported so `oneclient_core::packages::ContentType` keeps working pub use activity::{disable_foreign_game_versions, reconcile_duplicate_activity}; +pub use bad_mods::{ + BadMod, BadModAlternative, BadModList, ProjectIds, ResolvedAlternative, fetch_bad_mods, + fetch_explanation, load_bad_mods, refresh_bad_mods, resolve_alternatives, +}; pub use dependencies::{ DependencyResolution, ResolvedDependency, resolve_required, resolves_dependencies, }; diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs index 17357f95..6ad5102d 100644 --- a/packages/oneclient_core/src/state.rs +++ b/packages/oneclient_core/src/state.rs @@ -139,9 +139,10 @@ pub fn run_startup_tasks(state: &Arc) { crate::game::recover_sessions(&background).await; let content = background.services.content(); - let (versions_res, bundles_res) = tokio::join!( + let (versions_res, bundles_res, _) = tokio::join!( background.versions.sync(&background.services), background.bundles.sync(&content), + oneclient_content::packages::load_bad_mods(&content), ); if let Err(err) = versions_res { tracing::error!("versions manifest sync failed: {err:#}");