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>
This commit is contained in:
Maciek 2026-09-28 17:40:00 +02:00 • committed by GitHub
parent 695406145f
commit f94ca969b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1156 additions and 17 deletions

View file

@ -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()
}),
)
}

View file

@ -1,6 +1,6 @@
use freya::prelude::*; 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::hooks::use_link_confirm;
use crate::theme::colors; use crate::theme::colors;
use crate::ui::border_all_color; use crate::ui::border_all_color;
@ -20,6 +20,7 @@ impl Component for ConfirmLinkOverlay {
let open_url = url.clone(); let open_url = url.clone();
OverlayPopup::new() OverlayPopup::new()
.overlay_level(OVERLAY_BASE_LEVEL + 3)
.on_close(move |_| pending.set(None)) .on_close(move |_| pending.set(None))
.child( .child(
rect() rect()

View file

@ -12,6 +12,7 @@ mod date_range_picker;
mod dropdown; mod dropdown;
mod dynamic_art; mod dynamic_art;
mod file_drop; mod file_drop;
mod flagged_install_popup;
mod generic_prompt; mod generic_prompt;
mod icons; mod icons;
mod java_install_manager; mod java_install_manager;
@ -62,6 +63,7 @@ pub use date_range_picker::{DateRange, DateRangePicker};
pub use dropdown::Dropdown; pub use dropdown::Dropdown;
pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt}; pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt};
pub use file_drop::FileDropOverlay; pub use file_drop::FileDropOverlay;
pub use flagged_install_popup::FlaggedInstallPopup;
pub use generic_prompt::GenericPromptOverlay; pub use generic_prompt::GenericPromptOverlay;
pub use icons::{Icon, IconTint, IconType}; pub use icons::{Icon, IconTint, IconType};
pub use java_install_manager::JavaInstallManager; pub use java_install_manager::JavaInstallManager;
@ -80,7 +82,9 @@ pub use notifications::NotificationCenter;
pub use optional_mods_popup::OptionalModsPopup; pub use optional_mods_popup::OptionalModsPopup;
pub use overlay_popup::{OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL, OverlayPopup, overlay_is_topmost}; pub use overlay_popup::{OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL, OverlayPopup, overlay_is_topmost};
pub use package_delete_dialog::use_shared_delete; 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::{ pub use package_row::{
CardLayout, PackageEntry, PackageRow, github_badge, package_context_menu, provider_badge, CardLayout, PackageEntry, PackageRow, github_badge, package_context_menu, provider_badge,
}; };

View file

@ -659,8 +659,15 @@ pub(crate) fn package_icon(
icon_query: &freya::query::UseQuery<crate::hooks::CachedImageQuery>, icon_query: &freya::query::UseQuery<crate::hooks::CachedImageQuery>,
size: f32, size: f32,
) -> Element { ) -> Element {
let icon_url = &item.icon_url; remote_icon(item.icon_url.as_deref(), icon_query, size)
let loaded = loaded_image(icon_url.as_deref(), icon_query); }
pub(crate) fn remote_icon(
icon_url: Option<&str>,
icon_query: &freya::query::UseQuery<crate::hooks::CachedImageQuery>,
size: f32,
) -> Element {
let loaded = loaded_image(icon_url, icon_query);
match loaded { match loaded {
Some((url, bytes)) => ImageViewer::new((url, bytes)) Some((url, bytes)) => ImageViewer::new((url, bytes))

View file

@ -28,7 +28,7 @@ use crate::notifications::{
ClusterUpdateSummary, NotificationAction, NotificationSpec, OptionalModRef, OptionalModsGroup, ClusterUpdateSummary, NotificationAction, NotificationSpec, OptionalModRef, OptionalModsGroup,
OptionalModsOutcome, PackageUpdateGroup, PendingPrompt, OptionalModsOutcome, PackageUpdateGroup, PendingPrompt,
}; };
use crate::state::{AppChannel, AppState, AsyncStatus, RelocationState}; use crate::state::{AppChannel, AppState, AsyncStatus, FlaggedInstallPrompt, RelocationState};
use crate::{invalidate_java_queries, launcher}; use crate::{invalidate_java_queries, launcher};
/// Over-disabling is the cheaper mistake a dead button beats a second game /// Over-disabling is the cheaper mistake a dead button beats a second game
@ -1064,7 +1064,42 @@ impl Actions {
project_id: impl Into<String>, project_id: impl Into<String>,
version_id: impl Into<String>, version_id: impl Into<String>,
) { ) {
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(); let actions = self.clone();
self.station self.station
@ -1088,14 +1123,34 @@ impl Actions {
return; return;
}; };
let install = crate::install::install_package( let install = match crate::install::install_package(
&state, &state,
provider, provider,
&project_id, &project_id,
&version_id, &version_id,
cluster_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 // Replaces the download's progress notification in place rather
// than arriving as a second one // 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; 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 // Before the game process never after Minecraft reads its mods once at
// startup // startup
actions actions

View file

@ -207,6 +207,13 @@ pub struct PackageInstall {
pub live_deferred: bool, pub live_deferred: bool,
} }
pub struct FlaggedInstall {
pub name: String,
pub mc_version: String,
pub explanation: Option<String>,
pub alternatives: Vec<oneclient_content::packages::ResolvedAlternative>,
}
pub fn install_body( pub fn install_body(
name: &str, name: &str,
dependencies: &[String], 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( pub async fn install_package(
state: &Arc<LauncherState>, state: &Arc<LauncherState>,
provider: oneclient_content::packages::ProviderId, provider: oneclient_content::packages::ProviderId,
project_id: &str, project_id: &str,
version_id: &str, version_id: &str,
cluster_id: i64, cluster_id: i64,
) -> PackageInstall { allow_flagged: bool,
) -> Result<PackageInstall, FlaggedInstall> {
let lookup = async { let lookup = async {
let provider_impl = state.services.packages.get(provider)?; let provider_impl = state.services.packages.get(provider)?;
let project = provider_impl let project = provider_impl
@ -268,9 +308,17 @@ pub async fn install_package(
let (project, version) = match lookup { let (project, version) = match lookup {
Ok(found) => found, 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 // Resolved before the session starts so its children can be announced up front
let mut resolution = oneclient_content::packages::DependencyResolution::default(); let mut resolution = oneclient_content::packages::DependencyResolution::default();
if oneclient_content::packages::resolves_dependencies(project.content_type) { 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( let session = oneclient_events::GroupedProgressSession::start(
&state.services.events, &state.services.events,
format!("Installing {}", project.name), format!("Installing {}", project.name),
@ -380,13 +440,13 @@ pub async fn install_package(
mark_new(cluster_id, &artifact.hash, state).await; mark_new(cluster_id, &artifact.hash, state).await;
} }
PackageInstall { Ok(PackageInstall {
session_id: Some(session.detach()), session_id: Some(session.detach()),
result: result.map(|_| project.name).map_err(anyhow::Error::from), result: result.map(|_| project.name).map_err(anyhow::Error::from),
dependencies: installed_dependencies, dependencies: installed_dependencies,
missing_dependencies, missing_dependencies,
live_deferred, live_deferred,
} })
} }
async fn mark_new(cluster_id: i64, hash: &str, state: &LauncherState) { async fn mark_new(cluster_id: i64, hash: &str, state: &LauncherState) {

View file

@ -2,9 +2,10 @@ use freya::prelude::*;
use freya::router::*; use freya::router::*;
use crate::components::{ use crate::components::{
AccountSwitcher, ClusterUpdatePopup, ControlCenter, GenericPromptOverlay, JavaPromptOverlay, AccountSwitcher, ClusterUpdatePopup, ControlCenter, FlaggedInstallPopup, GenericPromptOverlay,
MicrosoftJavaPromptOverlay, NotificationCenter, OptionalModsPopup, PackageUpdatePopup, JavaPromptOverlay, MicrosoftJavaPromptOverlay, NotificationCenter, OptionalModsPopup,
SplashCurtain, StatusBar, Toasts, TooltipHost, UpdatePromptOverlay, use_provide_tooltips, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, TooltipHost, UpdatePromptOverlay,
use_provide_tooltips,
}; };
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
use crate::hooks::use_start_maximized; use crate::hooks::use_start_maximized;
@ -52,6 +53,7 @@ impl Component for RootLayout {
.child(ClusterUpdatePopup) .child(ClusterUpdatePopup)
.child(OptionalModsPopup) .child(OptionalModsPopup)
.child(PackageUpdatePopup) .child(PackageUpdatePopup)
.child(FlaggedInstallPopup)
.child(StatusBar) .child(StatusBar)
.child(TooltipHost) .child(TooltipHost)
.child(SplashCurtain) .child(SplashCurtain)

View file

@ -69,6 +69,19 @@ pub struct RelocationState {
#[derive(Clone, Debug, Default, PartialEq)] #[derive(Clone, Debug, Default, PartialEq)]
pub struct InstallState { pub struct InstallState {
pending: HashSet<(i64, ProviderId, String)>, pending: HashSet<(i64, ProviderId, String)>,
pub flagged: Option<FlaggedInstallPrompt>,
}
#[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<String>,
pub alternatives: Vec<oneclient_content::packages::ResolvedAlternative>,
} }
impl InstallState { impl InstallState {

View file

@ -76,7 +76,7 @@ fn type_title(package_type: &str) -> &'static str {
.map_or("Mods", |(_, title)| *title) .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) format!("{}:{}", provider as u8, id)
} }

View file

@ -2,6 +2,7 @@ mod index;
mod package; mod package;
pub use index::Browser; pub use index::Browser;
pub(crate) use index::encode_package_id;
pub use package::BrowserPackage; pub use package::BrowserPackage;
use std::collections::HashMap; use std::collections::HashMap;

View file

@ -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<BadMod>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct BadMod {
#[serde(default, deserialize_with = "non_blank")]
pub hash: Option<String>,
#[serde(default)]
pub project_ids: ProjectIds,
#[serde(default, deserialize_with = "non_blank")]
pub name: Option<String>,
#[serde(default, deserialize_with = "non_blank")]
pub author: Option<String>,
#[serde(default, deserialize_with = "non_blank")]
pub explanation: Option<String>,
#[serde(default, deserialize_with = "lenient_list")]
pub alternatives: Vec<BadModAlternative>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct BadModAlternative {
#[serde(default, deserialize_with = "non_blank")]
pub hash: Option<String>,
#[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<String>,
#[serde(default, deserialize_with = "project_id")]
pub curseforge: Option<String>,
}
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<Vec<T>, D::Error>
where
D: Deserializer<'de>,
T: DeserializeOwned,
{
let raw = Option::<Vec<serde_json::Value>>::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<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn non_blank<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
Ok(Option::<String>::deserialize(deserializer)?.and_then(blank_to_none))
}
fn project_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<String>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum RawId {
Text(String),
Number(u64),
}
Ok(match Option::<RawId>::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<String>,
pub icon_url: Option<String>,
}
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<BadModList> {
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<Option<Arc<BadModList>>> = RwLock::new(None);
pub async fn refresh_bad_mods(ctx: &ContentCtx) -> ContentResult<Arc<BadModList>> {
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<BadModList> {
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<String> {
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<ResolvedAlternative> {
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<FileIdentity> = 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<String>,
ctx: &ContentCtx,
) -> Vec<ProjectDetail> {
if ids.is_empty() {
return Vec::new();
}
let ids: Vec<String> = 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<ResolvedAlternative> {
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());
}
}

View file

@ -1,4 +1,5 @@
pub mod activity; pub mod activity;
pub mod bad_mods;
pub mod dependencies; pub mod dependencies;
pub mod error; pub mod error;
pub mod local_manifest; pub mod local_manifest;
@ -14,6 +15,10 @@ pub mod markdown;
// Re-exported so `oneclient_core::packages::ContentType` keeps working // Re-exported so `oneclient_core::packages::ContentType` keeps working
pub use activity::{disable_foreign_game_versions, reconcile_duplicate_activity}; 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::{ pub use dependencies::{
DependencyResolution, ResolvedDependency, resolve_required, resolves_dependencies, DependencyResolution, ResolvedDependency, resolve_required, resolves_dependencies,
}; };

View file

@ -139,9 +139,10 @@ pub fn run_startup_tasks(state: &Arc<LauncherState>) {
crate::game::recover_sessions(&background).await; crate::game::recover_sessions(&background).await;
let content = background.services.content(); 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.versions.sync(&background.services),
background.bundles.sync(&content), background.bundles.sync(&content),
oneclient_content::packages::load_bad_mods(&content),
); );
if let Err(err) = versions_res { if let Err(err) = versions_res {
tracing::error!("versions manifest sync failed: {err:#}"); tracing::error!("versions manifest sync failed: {err:#}");