feat: polyplus mark and removal warning (#877)

* feat: polyplus mark and removal warning (some issues with merging)

* chore: deleted the blue background from the polyplus mod

* feat: added warning for removing bundle mods; fixed the new/updated badges on mods cards
This commit is contained in:
Maciek 2026-09-29 12:38:05 +02:00 • committed by GitHub
parent 45da20fac2
commit 495dbf05dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 563 additions and 49 deletions

View file

@ -0,0 +1,148 @@
use freya::prelude::*;
use crate::components::{
Button, Icon, IconType, Markdown, MarkdownStyle, OverlayPopup, ScrollArea,
};
use crate::hooks::{EssentialGuardKind, use_cluster_mutation, use_essential_guard};
use crate::theme::colors;
use crate::ui::border_all_color;
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
const BODY_MAX_H: f32 = 400.;
#[derive(PartialEq)]
pub struct EssentialConfirmOverlay;
impl Component for EssentialConfirmOverlay {
fn render(&self) -> impl IntoElement {
let mut pending = use_essential_guard();
let cluster = use_cluster_mutation();
let Some(request) = pending.read().clone() else {
return rect().into_element();
};
let (title, confirm_label) = match request.kind {
EssentialGuardKind::Disable => (format!("Turn off {}?", request.name), "Turn it off"),
EssentialGuardKind::Remove => (format!("Remove {}?", request.name), "Remove anyway"),
};
let action = request.action.clone();
OverlayPopup::new()
.on_close(move |_| pending.set(None))
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(
rect()
.vertical()
.width(Size::px(440.))
.max_width(Size::window_percent(90.))
.spacing(14.)
.padding(Gaps::new_all(20.))
.corner_radius(CornerRadius::new_all(14.))
.background(CARD_BG)
.border(border_all_color(1., colors::component_border()))
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(10.)
.child(
Icon::new(IconType::AlertTriangle)
.size(20.)
.color(colors::code_warn()),
)
.child(
label()
.text(title)
.font_size(16.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
),
)
.child(WarningBody {
body: request.body.clone(),
})
.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::End)
.spacing(8.)
.child(
Button::new()
.secondary()
.on_press(move |_| {
cluster.mutate(action.clone());
pending.set(None);
})
.text(confirm_label),
)
.child(
Button::new()
.primary()
.on_press(move |_| pending.set(None))
.text("Keep it"),
),
),
),
)
.into_element()
}
}
#[derive(PartialEq)]
struct WarningBody {
body: String,
}
impl Component for WarningBody {
fn render(&self) -> impl IntoElement {
let mut content_h = use_state(|| 0f32);
let height = content_h.read().min(BODY_MAX_H);
ScrollArea::new()
.width(Size::fill())
.height(Size::px(height))
.scrollbar_gutter(true)
.child(
rect()
.width(Size::fill())
.on_sized(move |e: Event<SizedEventData>| {
let h = e.area.height();
if (*content_h.read() - h).abs() > 0.5 {
content_h.set(h);
}
})
.child(
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(self.body.clone())
.width(Size::fill())
.style(MarkdownStyle {
color: Color::WHITE,
color_link: colors::code_info(),
color_code: Color::WHITE,
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

@ -11,6 +11,7 @@ mod control_center;
mod date_range_picker;
mod dropdown;
mod dynamic_art;
mod essential_confirm;
mod file_drop;
mod flagged_install_popup;
mod generic_prompt;
@ -63,6 +64,7 @@ pub use control_center::ControlCenter;
pub use date_range_picker::{DateRange, DateRangePicker};
pub use dropdown::Dropdown;
pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt};
pub use essential_confirm::EssentialConfirmOverlay;
pub use file_drop::FileDropOverlay;
pub use flagged_install_popup::FlaggedInstallPopup;
pub use generic_prompt::GenericPromptOverlay;

View file

@ -101,6 +101,7 @@ fn entry_from_item(
.and_then(|project_id| meta.get(&(item.provider, project_id.clone())));
PackageEntry {
essential: crate::essential::lookup(item.provider, &package_id),
package_id,
bundle_name: item.offer.as_ref().map(|(bundle, _)| bundle.clone()),
provider: item.provider,

View file

@ -4,7 +4,12 @@ use oneclient_content::packages::ProviderId;
use oneclient_core::SeenStatus;
use crate::components::{ContextMenu, Icon, IconType, toggle_controlled};
use crate::hooks::{ClusterAction, loaded_image, use_cached_image, use_cluster_mutation};
use crate::essential::EssentialPackage;
use crate::hooks::{
ClusterAction, EssentialGuardKind, PendingEssential, disable_warnings,
loaded_image, use_cached_image, use_cluster_mutation, use_disable_warnings,
use_essential_guard,
};
use crate::routes::Route;
use crate::theme::colors;
use crate::ui::{ImageFallbackExt, border_all_color};
@ -16,6 +21,10 @@ pub(crate) const CARD_H: f32 = 84.;
pub(crate) const CARD_GRID_H: f32 = 112.;
pub(crate) const GRID_GAP: f32 = 12.;
pub(crate) const GRID_MIN_W: f32 = 290.;
const GRID_CARD_PADDING: f32 = 14.;
const BADGE_STRIP_H: f32 = 20.;
const BADGE_STRIP_LIFT: f32 = 2.;
const TAG_LETTER_SPACING: f32 = 0.6;
#[derive(Clone, Copy, PartialEq)]
pub enum CardLayout {
@ -57,6 +66,7 @@ pub struct PackageEntry {
pub update_available: bool,
/// Recency badge state cleared once the user views the list
pub seen_status: SeenStatus,
pub essential: Option<&'static EssentialPackage>,
}
impl PackageEntry {
@ -128,8 +138,12 @@ impl Component for PackageRow {
let package_type = self.package_type;
let layout = self.layout;
let cluster = use_cluster_mutation();
let guard = use_essential_guard();
let warnings_query = use_disable_warnings();
let hovered = use_state(|| false);
let warning_body = disable_warning_body(&item, disable_warnings(&warnings_query));
let icon_size = match layout {
CardLayout::List => 44.,
CardLayout::Grid => 40.,
@ -143,21 +157,35 @@ impl Component for PackageRow {
let package_id = item.package_id.clone();
let enabled_now = item.enabled;
let manifest_default = item.manifest_default;
let name = item.name.clone();
let mut guard = guard;
(move |()| {
if let Some(h) = &hash {
cluster.mutate(ClusterAction::SetArtifactEnabled {
let action = if let Some(h) = &hash {
ClusterAction::SetArtifactEnabled {
cluster_id,
hash: h.clone(),
enabled: !enabled_now,
});
}
} else if let Some(bundle) = &bundle_name {
cluster.mutate(ClusterAction::SetBundlePackageEnabled {
ClusterAction::SetBundlePackageEnabled {
cluster_id,
bundle_name: bundle.clone(),
package_id: package_id.clone(),
enabled: !enabled_now,
manifest_default,
});
}
} else {
return;
};
match warning_body.clone().filter(|_| enabled_now) {
Some(body) => guard.set(Some(PendingEssential {
name: name.clone(),
body,
kind: EssentialGuardKind::Disable,
action,
})),
None => cluster.mutate(action),
}
})
.into()
@ -166,9 +194,7 @@ impl Component for PackageRow {
let on_context = has_menu(&item).then(|| self.on_context.clone());
match layout {
CardLayout::List => {
list_card(&item, package_type, cluster_id, icon, on_toggle, on_context)
}
CardLayout::List => list_card(&item, package_type, cluster_id, icon, on_toggle, on_context),
CardLayout::Grid => grid_card(
&item,
package_type,
@ -183,6 +209,18 @@ impl Component for PackageRow {
}
}
fn disable_warning_body(
item: &PackageEntry,
warnings: Option<oneclient_core::DisableWarnings>,
) -> Option<String> {
let bundled = item.in_bundle() && item.provider == ProviderId::Modrinth;
match warnings {
Some(warnings) if bundled => warnings.body_for(&item.package_id).map(str::to_string),
_ => item.essential.map(|package| package.disable_body.to_string()),
}
}
pub fn has_menu(item: &PackageEntry) -> bool {
item.is_remote() || item.hash.is_some()
}
@ -353,7 +391,13 @@ pub(crate) fn grid_card(
let floating = (item.is_outdated() || item.recency_badge().is_some()).then(|| {
rect()
.horizontal()
.position(Position::new_absolute().bottom(10.).right(10.))
.width(Size::fill())
.height(Size::px(BADGE_STRIP_H))
.position(
Position::new_absolute()
.bottom(-(GRID_CARD_PADDING + BADGE_STRIP_H / 2.) + BADGE_STRIP_LIFT),
)
.main_align(Alignment::End)
.cross_align(Alignment::Center)
.spacing(4.)
.maybe_child(item.is_outdated().then(outdated_badge))
@ -366,11 +410,10 @@ pub(crate) fn grid_card(
.width(Size::fill())
.height(Size::fill())
.spacing(9.)
.padding(Gaps::new_all(14.))
.padding(Gaps::new_all(GRID_CARD_PADDING))
.corner_radius(CornerRadius::new_all(6.))
.background(bg.with_a(alpha))
.border(border_all_color(1., border))
.overflow(Overflow::Clip)
.content(Content::Flex)
.cursor(CursorIcon::Pointer)
.on_pointer_enter(move |_| hovered.set(true))
@ -383,6 +426,7 @@ pub(crate) fn grid_card(
.vertical()
.width(Size::fill())
.height(Size::flex(1.0))
.overflow(Overflow::Clip)
.maybe_child(description),
)
.maybe_child(floating)
@ -727,36 +771,37 @@ pub fn github_badge() -> Element {
}
fn outdated_badge() -> Element {
accent_badge(
Icon::new(IconType::RefreshCw01)
.size(12.)
.color(colors::brand())
.into_element(),
"Update available".to_string(),
colors::brand(),
)
status_tag("Update available", colors::brand())
}
fn new_badge() -> Element {
accent_badge(
Icon::new(IconType::Plus)
.size(12.)
.color(colors::success())
.into_element(),
"New".to_string(),
colors::success(),
)
status_tag("New", colors::success())
}
fn updated_badge() -> Element {
accent_badge(
Icon::new(IconType::RefreshCcw02)
.size(12.)
.color(colors::success())
.into_element(),
"Updated".to_string(),
colors::success(),
)
status_tag("Updated", colors::success())
}
fn status_tag(text: &str, accent: Color) -> Element {
rect()
.corner_radius(CornerRadius::new_all(4.))
.background(colors::component_bg())
.child(
rect()
.padding(Gaps::new(1., 6., 3., 6. + TAG_LETTER_SPACING))
.corner_radius(CornerRadius::new_all(4.))
.background(accent.with_a(38))
.border(border_all_color(1., accent.with_a(96)))
.child(
label()
.text(text.to_uppercase())
.font_size(9.)
.font_weight(FontWeight::BOLD)
.letter_spacing(TAG_LETTER_SPACING)
.color(accent),
),
)
.into_element()
}
fn local_badge() -> Element {

View file

@ -0,0 +1,44 @@
use oneclient_content::packages::ProviderId;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EssentialPackage {
pub provider: ProviderId,
pub project_id: &'static str,
pub name: &'static str,
pub disable_body: &'static str,
pub remove_body: &'static str,
}
pub const ESSENTIAL_PACKAGES: &[EssentialPackage] = &[EssentialPackage {
provider: ProviderId::Modrinth,
project_id: "Iw9mZi4a",
name: "PolyPlus+",
disable_body: "PolyPlus+ powers your Poly+ cosmetics and playtime tracking. Turning it off disables them in game, and it stays off through future bundle updates until you turn it back on.",
remove_body: "PolyPlus+ powers your Poly+ cosmetics and playtime tracking. Removing it disables them in game and deletes the file from this cluster, so you would have to install it again from the Browser.",
}];
pub fn lookup(provider: ProviderId, package_id: &str) -> Option<&'static EssentialPackage> {
ESSENTIAL_PACKAGES
.iter()
.find(|package| package.provider == provider && package.project_id == package_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn polyplus_is_essential_on_modrinth_only() {
assert_eq!(
lookup(ProviderId::Modrinth, "Iw9mZi4a").map(|p| p.name),
Some("PolyPlus+")
);
assert!(lookup(ProviderId::CurseForge, "Iw9mZi4a").is_none());
assert!(lookup(ProviderId::Local, "Iw9mZi4a").is_none());
}
#[test]
fn ordinary_packages_are_not_guarded() {
assert!(lookup(ProviderId::Modrinth, "AibBIVmj").is_none());
}
}

View file

@ -5,6 +5,8 @@ use oneclient_content::packages::ProviderId;
use oneclient_content::packages::types::SearchSort;
use oneclient_db::models::ClusterId;
use crate::hooks::ClusterAction;
#[derive(Clone)]
pub struct ActiveClusterState(pub State<Option<ClusterId>>);
@ -91,6 +93,31 @@ pub fn use_link_confirm() -> State<Option<String>> {
consume_root_context::<LinkConfirmState>().0
}
#[derive(Clone, Copy, PartialEq)]
pub enum EssentialGuardKind {
Disable,
Remove,
}
#[derive(Clone, PartialEq)]
pub struct PendingEssential {
pub name: String,
pub body: String,
pub kind: EssentialGuardKind,
pub action: ClusterAction,
}
#[derive(Clone)]
pub struct EssentialGuardState(pub State<Option<PendingEssential>>);
pub fn use_provide_essential_guard(state: EssentialGuardState) {
use_provide_root_context(move || state.clone());
}
pub fn use_essential_guard() -> State<Option<PendingEssential>> {
consume_root_context::<EssentialGuardState>().0
}
#[derive(Clone)]
pub struct BrowserUiState {
pub query: String,

View file

@ -15,20 +15,21 @@ pub use view_state::{PersistedView, use_view_state};
pub use active_cluster::{
ActiveClusterState, BROWSER_COMPAT_DEFAULT, BrowserCompatState, BrowserStateStore,
BrowserTypeState, BrowserUiState, DataPackWorldState, LinkConfirmState,
OnboardingSelectionState, SplashState, StartMaximizedState, use_active_cluster_id,
use_browser_compat, use_browser_state_store, use_browser_type, use_datapack_world,
use_link_confirm, use_onboarding_selection, use_provide_active_cluster,
use_provide_browser_compat, use_provide_browser_state, use_provide_browser_type,
use_provide_datapack_world, use_provide_link_confirm, use_provide_onboarding_selection,
use_provide_splash, use_provide_start_maximized, use_splash, use_start_maximized,
BrowserTypeState, BrowserUiState, DataPackWorldState, EssentialGuardKind, EssentialGuardState,
LinkConfirmState, OnboardingSelectionState, PendingEssential, SplashState, StartMaximizedState,
use_active_cluster_id, use_browser_compat, use_browser_state_store, use_browser_type,
use_datapack_world, use_essential_guard, use_link_confirm, use_onboarding_selection,
use_provide_active_cluster, use_provide_browser_compat, use_provide_browser_state,
use_provide_browser_type, use_provide_datapack_world, use_provide_essential_guard,
use_provide_link_confirm, use_provide_onboarding_selection, use_provide_splash,
use_provide_start_maximized, use_splash, use_start_maximized,
};
pub use actions::{Actions, NotificationBuilder, PumpSignal};
pub use queries::{
AddOfflineAccountKeys, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, CachedImageQuery,
CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles,
ClusterLogsQuery, ClusterMutation, DiscardLeftoversKeys, DiscardLeftoversMutation,
ClusterLogsQuery, ClusterMutation, DisableWarningsQuery, DiscardLeftoversKeys, DiscardLeftoversMutation,
FinishMicrosoftLoginMutation, LeftoversQuery, LogAction, LogContentQuery, MigrationQuery,
OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction,
SetDefaultAccountKeys, StorageAction, StorageActionMutation, StorageReportQuery, TermsQuery,
@ -36,7 +37,7 @@ pub use queries::{
UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, UseStorageAction, UseUploadLog,
VERSIONS_PAGE_SIZE, accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items,
category_list, changelog_error, changelog_groups, changelog_is_loading, cluster_content_items,
content_type_for_slug, has_migration_data, invalidate_cluster_content_queries,
content_type_for_slug, disable_warnings, has_migration_data, invalidate_cluster_content_queries,
invalidate_cluster_queries, invalidate_java_queries, invalidate_leftovers_queries,
invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries,
invalidate_storage_queries, java_runtimes, latest_changelog_version, loaded_image,
@ -53,7 +54,7 @@ pub use queries::{
use_cancel_microsoft_login, use_changelog, use_cluster, use_cluster_analytics,
use_cluster_content, use_cluster_logs, use_cluster_mutation, use_cluster_profile,
use_cluster_screenshots, use_cluster_settings, use_clusters, use_current_account,
use_default_account, use_discard_leftovers, use_finish_microsoft_login, use_game_profile,
use_default_account, use_disable_warnings, use_discard_leftovers, use_finish_microsoft_login, use_game_profile,
use_global_analytics, use_java_runtimes, use_leftovers, use_loader_versions, use_local_image,
use_log_action, use_log_content, use_migration, use_named_profiles, use_onboarding_bundles,
use_package_categories, use_package_meta_batch, use_package_project, use_package_search,

View file

@ -0,0 +1,34 @@
use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_core::{DisableWarnings, LauncherError, fetch_disable_warnings};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct DisableWarningsKeys {
pub meta_url_base: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct DisableWarningsQuery;
impl QueryCapability for DisableWarningsQuery {
type Ok = DisableWarnings;
type Err = LauncherError;
type Keys = DisableWarningsKeys;
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = crate::launcher::state()?;
fetch_disable_warnings(&state.services.requester).await
}
}
pub fn use_disable_warnings() -> UseQuery<DisableWarningsQuery> {
let meta_url_base = super::use_meta_url_key();
use_query(Query::new(
DisableWarningsKeys { meta_url_base },
DisableWarningsQuery,
))
}
pub fn disable_warnings(query: &UseQuery<DisableWarningsQuery>) -> Option<DisableWarnings> {
super::state::settled_or_loading(query)
}

View file

@ -15,6 +15,7 @@ mod bundles;
mod changelog;
mod cluster_content;
mod clusters;
mod disable_warnings;
mod folder_watch;
mod image;
mod java;
@ -59,6 +60,7 @@ pub use changelog::{
};
pub use cluster_content::{cluster_content_items, use_cluster_content};
pub use clusters::{use_cluster, use_clusters};
pub use disable_warnings::{DisableWarningsQuery, disable_warnings, use_disable_warnings};
pub use image::{CachedImageQuery, loaded_image, use_cached_image};
pub use java::{
invalidate_java_queries, java_runtimes, provider_versions, use_java_runtimes,

View file

@ -6,6 +6,7 @@
mod assets;
pub mod cli;
mod components;
pub mod essential;
pub mod events;
pub(crate) mod file_content;
pub mod hooks;
@ -32,7 +33,7 @@ mod view;
pub mod constants;
pub use assets::AppAssets;
pub use components::ConfirmLinkOverlay;
pub use components::{ConfirmLinkOverlay, EssentialConfirmOverlay};
pub use events::EventPump;
pub use hooks::*;
pub use routes::{Route, router};

View file

@ -9,8 +9,9 @@ use freya::radio::use_init_radio_station;
use oneclient_app::ipc::{self, Claim};
use oneclient_app::state::{AppChannel, AppState, LauncherInit};
use oneclient_app::{
Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, StartMaximizedState, cli, constants,
events, microsoft_java, platform, router, theme, use_provide_actions, use_provide_link_confirm,
Actions, ConfirmLinkOverlay, EssentialConfirmOverlay, EssentialGuardState, EventPump,
LinkConfirmState, StartMaximizedState, cli, constants, events, microsoft_java, platform,
router, theme, use_provide_actions, use_provide_essential_guard, use_provide_link_confirm,
use_provide_start_maximized,
};
use std::cell::Cell;
@ -82,12 +83,16 @@ impl App for OneClientApp {
let link_confirm = use_state(|| None::<String>);
use_provide_link_confirm(LinkConfirmState(link_confirm));
let essential_guard = use_state(|| None);
use_provide_essential_guard(EssentialGuardState(essential_guard));
use_provide_start_maximized(StartMaximizedState(self.start_maximized));
rect()
.width(Size::fill())
.height(Size::fill())
.child(ConfirmLinkOverlay)
.child(EssentialConfirmOverlay)
.child(router())
}
}

View file

@ -276,6 +276,7 @@ fn make_row(
.unwrap_or_default();
PackageEntry {
essential: crate::essential::lookup(provider, &package_id),
package_id,
bundle_name,
provider,
@ -474,6 +475,12 @@ impl Component for PackageManager {
let hidden = *hidden_filter.read();
let card_layout = CardLayout::from(*layout.read());
let disabled_essentials: Vec<&'static str> = items
.iter()
.filter(|package| !package.enabled)
.filter_map(|package| package.essential.map(|essential| essential.name))
.collect();
let tabs = build_tabs(&self.categories, &items, hidden);
let active_idx = (*active.read()).min(tabs.len().saturating_sub(1));
let tab_filter = tabs.get(active_idx);
@ -508,6 +515,9 @@ impl Component for PackageManager {
if session_live {
notices.push(views::running_notice(noun_plural, content_type));
}
if !disabled_essentials.is_empty() {
notices.push(views::essential_notice(&disabled_essentials));
}
let (advanced, filtered): (Vec<_>, Vec<_>) = filtered.into_iter().partition(|p| p.advanced);

View file

@ -360,6 +360,16 @@ pub(super) fn global_notice(noun_plural: &'static str) -> String {
)
}
pub(super) fn essential_notice(names: &[&'static str]) -> String {
match names {
[only] => format!("{only} is turned off, so its features will not work in game."),
_ => format!(
"{} are turned off, so their features will not work in game.",
names.join(", ")
),
}
}
pub(crate) fn notice_bar(text: String) -> Element {
rect()
.horizontal()

View file

@ -0,0 +1,182 @@
use std::collections::{HashMap, HashSet};
use serde::Deserialize;
use crate::{LauncherError, LauncherResult};
use oneclient_common::paths;
use oneclient_net::RequestClient;
use oneclient_net::{EtagPolicy, fetch_cached};
const MESSAGES_DIR: &str = "/oneclient/bundles/disable-warnings/";
#[derive(Debug, Default, Deserialize)]
struct Manifest {
#[serde(default)]
mods: HashMap<String, Entry>,
}
#[derive(Debug, Deserialize)]
struct Entry {
message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DisableWarnings {
mods: HashMap<String, String>,
}
impl DisableWarnings {
pub fn body_for(&self, project_id: &str) -> Option<&str> {
self.mods.get(project_id).map(String::as_str)
}
}
fn message_file_name(path: &str) -> Option<&str> {
let name = path.strip_prefix(MESSAGES_DIR)?;
let valid = name.ends_with(".md")
&& name.len() > ".md".len()
&& !name.contains("..")
&& !name.contains(['/', '\\', '?', '#']);
valid.then_some(name)
}
async fn fetch_message(net: &RequestClient, path: &str) -> Option<String> {
let Some(file_name) = message_file_name(path) else {
tracing::warn!(path, "ignoring disable warning outside {MESSAGES_DIR}");
return None;
};
let url = format!("{}{path}", net.config().meta_url_base);
let cache_path = match paths::caches_dir() {
Ok(dir) => dir.join("disable-warnings").join(file_name),
Err(err) => {
tracing::warn!(%err, "cannot resolve the cache dir for disable warnings");
return None;
}
};
match fetch_cached(net, &url, &cache_path, EtagPolicy::CommitNow).await {
Ok(Some(fetched)) => Some(fetched.text()).filter(|body| !body.trim().is_empty()),
Ok(None) => {
tracing::warn!(path, "disable warning is unavailable and not cached");
None
}
Err(err) => {
tracing::warn!(path, %err, "failed to fetch disable warning");
None
}
}
}
fn resolve(manifest: Manifest, bodies: &HashMap<String, String>) -> DisableWarnings {
let mods = manifest
.mods
.into_iter()
.filter_map(|(project_id, entry)| {
bodies
.get(&entry.message)
.map(|body| (project_id, body.clone()))
})
.collect();
DisableWarnings { mods }
}
#[tracing::instrument(level = "debug", skip(net))]
pub async fn fetch_disable_warnings(net: &RequestClient) -> LauncherResult<DisableWarnings> {
let url = format!(
"{}/oneclient/bundles/disable-warnings.json",
net.config().meta_url_base
);
let cache_path = paths::caches_dir()?.join("disable-warnings.json");
let fetched = fetch_cached(net, &url, &cache_path, EtagPolicy::CommitNow)
.await?
.ok_or_else(|| LauncherError::InvalidSettingsProfile {
reason: "disable warnings are unavailable and not cached".to_string(),
})?;
let manifest: Manifest = fetched.json()?;
let paths: HashSet<&str> = manifest
.mods
.values()
.map(|entry| entry.message.as_str())
.collect();
let mut bodies = HashMap::new();
for path in paths {
if let Some(body) = fetch_message(net, path).await {
bodies.insert(path.to_string(), body);
}
}
Ok(resolve(manifest, &bodies))
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(raw: &str) -> Manifest {
serde_json::from_str(raw).unwrap()
}
fn bodies(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(path, body)| (path.to_string(), body.to_string()))
.collect()
}
#[test]
fn only_listed_mods_get_a_warning() {
let manifest = parse(
r#"{"mods":{"Iw9mZi4a":{"message":"/oneclient/bundles/disable-warnings/polyplus.md"}}}"#,
);
let warnings = resolve(
manifest,
&bodies(&[("/oneclient/bundles/disable-warnings/polyplus.md", "Poly+")]),
);
assert_eq!(warnings.body_for("Iw9mZi4a"), Some("Poly+"));
assert_eq!(warnings.body_for("AANobbMI"), None);
}
#[test]
fn unfetched_message_means_no_warning() {
let manifest = parse(
r#"{"mods":{"Iw9mZi4a":{"message":"/oneclient/bundles/disable-warnings/polyplus.md"}}}"#,
);
let warnings = resolve(manifest, &HashMap::new());
assert_eq!(warnings.body_for("Iw9mZi4a"), None);
}
#[test]
fn empty_manifest_means_no_warnings() {
let warnings = resolve(parse("{}"), &HashMap::new());
assert_eq!(warnings.body_for("Iw9mZi4a"), None);
}
#[test]
fn message_paths_are_confined_to_the_warnings_dir() {
assert_eq!(
message_file_name("/oneclient/bundles/disable-warnings/polyplus.md"),
Some("polyplus.md")
);
assert_eq!(
message_file_name("/oneclient/bundles/disable-warnings/../x.md"),
None
);
assert_eq!(
message_file_name("/oneclient/bundles/disable-warnings/a/b.md"),
None
);
assert_eq!(message_file_name("/oneclient/tos.json"), None);
assert_eq!(
message_file_name("/oneclient/bundles/disable-warnings/.md"),
None
);
}
}

View file

@ -5,6 +5,7 @@ pub mod dev;
pub mod changelog;
pub mod clusters;
pub mod disable_warnings;
mod error;
pub mod game;
pub mod images;
@ -27,6 +28,7 @@ pub use clusters::{
Cluster, ClusterError, ClusterManager, ClusterStage, ClusterUpdate, CreateClusterOptions,
ensure_from_bundles, ensure_from_versions, estimate_cluster_download, required_java_major,
};
pub use disable_warnings::{DisableWarnings, fetch_disable_warnings};
pub use error::{LauncherError, LauncherResult, SentryExclusion};
pub use game::{GameError, LaunchedGame, get_loader_versions, launch_cluster};
pub use images::ImageCacheStore;