From 495dbf05dc88330767027809e12a95dea132ac47 Mon Sep 17 00:00:00 2001 From: Maciek <108873945+rozwader@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:38:05 +0200 Subject: [PATCH] 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 --- .../src/components/essential_confirm.rs | 148 ++++++++++++++ packages/oneclient_app/src/components/mod.rs | 2 + .../src/components/optional_mods_popup.rs | 1 + .../src/components/package_row.rs | 117 +++++++---- packages/oneclient_app/src/essential.rs | 44 +++++ .../oneclient_app/src/hooks/active_cluster.rs | 27 +++ packages/oneclient_app/src/hooks/mod.rs | 21 +- .../src/hooks/queries/disable_warnings.rs | 34 ++++ .../oneclient_app/src/hooks/queries/mod.rs | 2 + packages/oneclient_app/src/lib.rs | 3 +- packages/oneclient_app/src/main.rs | 9 +- .../view/app/cluster/package_manager/mod.rs | 10 + .../view/app/cluster/package_manager/views.rs | 10 + .../oneclient_core/src/disable_warnings.rs | 182 ++++++++++++++++++ packages/oneclient_core/src/lib.rs | 2 + 15 files changed, 563 insertions(+), 49 deletions(-) create mode 100644 packages/oneclient_app/src/components/essential_confirm.rs create mode 100644 packages/oneclient_app/src/essential.rs create mode 100644 packages/oneclient_app/src/hooks/queries/disable_warnings.rs create mode 100644 packages/oneclient_core/src/disable_warnings.rs diff --git a/packages/oneclient_app/src/components/essential_confirm.rs b/packages/oneclient_app/src/components/essential_confirm.rs new file mode 100644 index 00000000..05393208 --- /dev/null +++ b/packages/oneclient_app/src/components/essential_confirm.rs @@ -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| { + 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() + }), + ), + ), + ) + } +} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 486f7e22..892c0824 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -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; diff --git a/packages/oneclient_app/src/components/optional_mods_popup.rs b/packages/oneclient_app/src/components/optional_mods_popup.rs index 53c7e554..c830623b 100644 --- a/packages/oneclient_app/src/components/optional_mods_popup.rs +++ b/packages/oneclient_app/src/components/optional_mods_popup.rs @@ -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, diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index 56ac5468..ed2af3e1 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -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, +) -> Option { + 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 { diff --git a/packages/oneclient_app/src/essential.rs b/packages/oneclient_app/src/essential.rs new file mode 100644 index 00000000..383f3330 --- /dev/null +++ b/packages/oneclient_app/src/essential.rs @@ -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()); + } +} diff --git a/packages/oneclient_app/src/hooks/active_cluster.rs b/packages/oneclient_app/src/hooks/active_cluster.rs index 7f727965..8d14d0a9 100644 --- a/packages/oneclient_app/src/hooks/active_cluster.rs +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -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>); @@ -91,6 +93,31 @@ pub fn use_link_confirm() -> State> { consume_root_context::().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>); + +pub fn use_provide_essential_guard(state: EssentialGuardState) { + use_provide_root_context(move || state.clone()); +} + +pub fn use_essential_guard() -> State> { + consume_root_context::().0 +} + #[derive(Clone)] pub struct BrowserUiState { pub query: String, diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 8a692c63..724429f8 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -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, diff --git a/packages/oneclient_app/src/hooks/queries/disable_warnings.rs b/packages/oneclient_app/src/hooks/queries/disable_warnings.rs new file mode 100644 index 00000000..d7bbd08f --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/disable_warnings.rs @@ -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 { + let state = crate::launcher::state()?; + fetch_disable_warnings(&state.services.requester).await + } +} + +pub fn use_disable_warnings() -> UseQuery { + let meta_url_base = super::use_meta_url_key(); + + use_query(Query::new( + DisableWarningsKeys { meta_url_base }, + DisableWarningsQuery, + )) +} + +pub fn disable_warnings(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index 526a6c36..d74a9b67 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -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, diff --git a/packages/oneclient_app/src/lib.rs b/packages/oneclient_app/src/lib.rs index d84c8b44..4535c13a 100644 --- a/packages/oneclient_app/src/lib.rs +++ b/packages/oneclient_app/src/lib.rs @@ -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}; diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs index 6438fc92..fee2dff9 100644 --- a/packages/oneclient_app/src/main.rs +++ b/packages/oneclient_app/src/main.rs @@ -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::); 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()) } } diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index b66fe1e4..34ae4ab7 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -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); diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 16f8e21c..c3d0a514 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -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() diff --git a/packages/oneclient_core/src/disable_warnings.rs b/packages/oneclient_core/src/disable_warnings.rs new file mode 100644 index 00000000..8954ea8f --- /dev/null +++ b/packages/oneclient_core/src/disable_warnings.rs @@ -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, +} + +#[derive(Debug, Deserialize)] +struct Entry { + message: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DisableWarnings { + mods: HashMap, +} + +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 { + 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) -> 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 { + 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 { + 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 + ); + } +} diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index 84f3dbbe..5d7ba768 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -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;