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:
parent
695406145f
commit
f94ca969b0
13 changed files with 1156 additions and 17 deletions
282
packages/oneclient_app/src/components/flagged_install_popup.rs
Normal file
282
packages/oneclient_app/src/components/flagged_install_popup.rs
Normal 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()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
701
packages/oneclient_content/src/packages/bad_mods.rs
Normal file
701
packages/oneclient_content/src/packages/bad_mods.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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:#}");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue