From 45a970c38abb8cf7351369398ddeb7b93c07fe40 Mon Sep 17 00:00:00 2001 From: sakuyma Date: Fri, 18 Sep 2026 21:43:49 +0000 Subject: [PATCH] Small QoL fixes: Java refresh, clipboard toasts, custom image endpoints (#852) * fix(java): add refresh button for stale runtimes * fix(notifications): keep clipboard toasts out of the notification center * fix(images): allow custom endpoints on non-public hosts * chore: make toast_only notifications not increment notification counter * chore: reorder navbar links --------- Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com> --- .../src/components/navbar/app_navbar.rs | 12 +-- .../src/components/notifications.rs | 12 ++- .../src/components/screenshot_viewer.rs | 2 + packages/oneclient_app/src/hooks/actions.rs | 77 +++++++++++++++++ packages/oneclient_app/src/install.rs | 1 + .../oneclient_app/src/layout/app_shell.rs | 2 + .../src/layout/settings_shell.rs | 2 + packages/oneclient_app/src/notifications.rs | 84 ++++++++++++++++++- .../src/view/app/settings/java.rs | 25 +++++- packages/oneclient_core/src/images/mod.rs | 5 +- packages/oneclient_net/src/config.rs | 75 ++++++++++++++--- 11 files changed, 272 insertions(+), 25 deletions(-) diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index 86e703ad..9c7ca691 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -139,12 +139,7 @@ fn navbar_center(is_small: bool) -> impl IntoElement { target: NavTarget::Route(Route::Clusters {}), nav_label: "Versions", }) - .child(NavLink { - active: false, - target: NavTarget::External("https://store.polyfrost.org"), - nav_label: "Cosmetics", - }) - .child(NavLink { + .child(NavLink { active: matches!( route, Route::Browser { @@ -155,6 +150,11 @@ fn navbar_center(is_small: bool) -> impl IntoElement { target: NavTarget::Route(browse_target), nav_label: "Browse", }) + .child(NavLink { + active: false, + target: NavTarget::External("https://store.polyfrost.org"), + nav_label: "Cosmetics", + }) } /// Falls back active cluster then most recently played then Versions when none exist diff --git a/packages/oneclient_app/src/components/notifications.rs b/packages/oneclient_app/src/components/notifications.rs index 6469ac41..af941850 100644 --- a/packages/oneclient_app/src/components/notifications.rs +++ b/packages/oneclient_app/src/components/notifications.rs @@ -48,9 +48,13 @@ impl Component for NotificationPanel { let progress = intro.read().value(); - let entries = inbox.len(); let mut rows: Vec = Vec::new(); - if entries == 0 { + // Toast-only notices are ephemeral: never surface them in the center + let visible: Vec = inbox + .into_iter() + .filter(|entry| !entry.toast_only) + .collect(); + if visible.is_empty() { rows.push( label() .text("No notifications") @@ -59,8 +63,8 @@ impl Component for NotificationPanel { .into_element(), ); } else { - let last = entries - 1; - for (i, entry) in inbox.into_iter().enumerate() { + let last = visible.len() - 1; + for (i, entry) in visible.into_iter().enumerate() { let id = entry.id; rows.push(NotifEntryRow::new(entry, i != last).key(id).into_element()); } diff --git a/packages/oneclient_app/src/components/screenshot_viewer.rs b/packages/oneclient_app/src/components/screenshot_viewer.rs index 5caae228..98ad22ee 100644 --- a/packages/oneclient_app/src/components/screenshot_viewer.rs +++ b/packages/oneclient_app/src/components/screenshot_viewer.rs @@ -182,6 +182,7 @@ impl Component for ScreenshotViewer { .body("Screenshot copied to your clipboard.") .info() .icon(IconType::ClipboardCheck) + .toast_only() .send(); }) .child(Icon::new(IconType::Copy01).size(14.)) @@ -235,6 +236,7 @@ pub fn screenshot_context_menu( .body("Screenshot copied to your clipboard.") .info() .icon(IconType::ClipboardCheck) + .toast_only() .send(); }) .separator() diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index bdfcbad9..269d103f 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -943,6 +943,7 @@ impl Actions { icon: None, progress: None, actions: Vec::new(), + toast_only: false, }, } } @@ -1116,6 +1117,7 @@ impl Actions { icon: Some(IconType::Download01), progress: None, actions: Vec::new(), + toast_only: false, }, Err(err) => NotificationSpec { title: "Install failed".to_string(), @@ -1124,6 +1126,7 @@ impl Actions { icon: None, progress: None, actions: Vec::new(), + toast_only: false, }, }; @@ -1619,6 +1622,7 @@ impl Actions { icon: Some(IconType::DownloadCloud02), progress: None, actions: Vec::new(), + toast_only: false, }); self.with_engine(|app| { @@ -1691,6 +1695,72 @@ impl Actions { wait.await.unwrap_or_default() } + pub fn apply_package_update(&self, update: oneclient_core::BrowserPackageUpdate) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + + let session = oneclient_events::GroupedProgressSession::start( + &events, + format!("Updating {}", update.display_name), + ); + let child = session.child( + update.display_name.clone(), + 1, + oneclient_events::TaskCategory::Packages, + ); + + let result = oneclient_core::apply_browser_package_update( + &update, + Some(&child), + &state.services.content(), + ) + .await; + + child.finish(); + let session_id = session.detach(); + + let spec = match &result { + Ok(_) => NotificationSpec { + title: "Updated".to_string(), + body: format!( + "{} is now on {}", + update.display_name, update.latest_version_name + ), + level: Level::Info, + icon: Some(IconType::DownloadCloud02), + progress: None, + actions: Vec::new(), + toast_only: false, + }, + Err(err) => NotificationSpec { + title: "Update failed".to_string(), + body: err.to_string(), + level: Level::Error, + icon: None, + progress: None, + actions: Vec::new(), + toast_only: false, + }, + }; + + actions.with_engine(|app| { + app.notifications + .finish_grouped_as_actions(&mut app.inbox, session_id, Some(spec)); + }); + + // A failed update stays in the list so the user can retry + if result.is_ok() { + actions.with_engine(|app| { + app.notifications + .resolve_package_update(update.cluster_id, &update.hash); + }); + super::invalidate_cluster_queries().await; + } + }); + } + /// The package stays marked out of date only the modal stops asking and /// only for this version pub fn skip_package_update(&self, cluster_id: ClusterId, hash: impl Into) { @@ -1901,6 +1971,13 @@ impl NotificationBuilder { self } + /// Marks the notice as ephemeral: it shows as a toast but never sticks + /// around in the notification center + pub fn toast_only(mut self) -> Self { + self.spec.toast_only = true; + self + } + pub fn send(self) { self.actions.push_notification(self.spec); } diff --git a/packages/oneclient_app/src/install.rs b/packages/oneclient_app/src/install.rs index dbf4f783..770ae4b7 100644 --- a/packages/oneclient_app/src/install.rs +++ b/packages/oneclient_app/src/install.rs @@ -165,6 +165,7 @@ pub async fn cluster_update_notification( label: "View changes".to_string(), kind: NotificationActionKind::OpenClusterUpdate(vec![summary]), }], + toast_only: false, }) } diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs index b6fc9a76..11b7191e 100644 --- a/packages/oneclient_app/src/layout/app_shell.rs +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -218,6 +218,7 @@ fn copy_error_button(message: &str, dispatch: crate::Actions) -> impl IntoElemen .notify("Copy failed") .body("Could not copy the error to the clipboard.") .error() + .toast_only() .send(); } else { dispatch @@ -225,6 +226,7 @@ fn copy_error_button(message: &str, dispatch: crate::Actions) -> impl IntoElemen .body("Error message copied to your clipboard.") .info() .icon(IconType::ClipboardCheck) + .toast_only() .send(); } }) diff --git a/packages/oneclient_app/src/layout/settings_shell.rs b/packages/oneclient_app/src/layout/settings_shell.rs index a1263fe7..6ad92676 100644 --- a/packages/oneclient_app/src/layout/settings_shell.rs +++ b/packages/oneclient_app/src/layout/settings_shell.rs @@ -758,6 +758,7 @@ impl Component for SidebarInfo { .notify("Copy failed") .body("Could not copy system information to the clipboard.") .error() + .toast_only() .send(); } else { dispatch @@ -765,6 +766,7 @@ impl Component for SidebarInfo { .body("System information copied to your clipboard.") .info() .icon(IconType::ClipboardCheck) + .toast_only() .send(); } }; diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index dd9cd48a..3da938d6 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -119,6 +119,10 @@ pub struct InboxEntry { pub tasks: Vec, /// Bytes/sec and seconds remaining for grouped downloads pub transfer: Option, + /// Toast-only notices still render as a toast but are never kept in the + /// notification center: they are dropped from the inbox when dismissed or + /// when their toast expires + pub toast_only: bool, } /// One aggregate row per [`TaskCategory`] (all libraries collapse into one "Libraries" row) @@ -140,6 +144,7 @@ pub struct NotificationSpec { pub icon: Option, pub progress: Option<(u64, u64)>, pub actions: Vec, + pub toast_only: bool, } impl InboxEntry { @@ -451,7 +456,10 @@ impl NotificationState { } pub fn unread_count(inbox: &[InboxEntry]) -> usize { - inbox.iter().filter(|entry| !entry.read).count() + inbox + .iter() + .filter(|entry| !entry.read && !entry.toast_only) + .count() } /// Deliberately does not snapshot this runs tens of thousands of times per download @@ -545,7 +553,7 @@ impl NotificationState { } let has_progress = entry.is_some_and(|e| e.progress.is_some()); self.active_toasts.remove(pos); - if !has_progress { + if !has_progress || entry.is_some_and(|e| e.toast_only) { self.forget_entry(inbox, entry_id); } } @@ -556,9 +564,16 @@ impl NotificationState { .retain(|toast| toast.toast_id != toast_id); } - pub fn expire_toast(&mut self, _inbox: &[InboxEntry], entry_id: u64) { + pub fn expire_toast(&mut self, inbox: &mut Vec, entry_id: u64) { self.active_toasts .retain(|toast| toast.entry_id != entry_id); + if inbox + .iter() + .find(|e| e.id == entry_id) + .is_some_and(|e| e.toast_only) + { + self.forget_entry(inbox, entry_id); + } } pub fn mark_read(&mut self, inbox: &mut [InboxEntry], entry_id: u64) { @@ -610,6 +625,7 @@ impl NotificationState { actions: Vec::new(), tasks: Vec::new(), transfer: None, + toast_only: false, }, ); id @@ -623,6 +639,7 @@ impl NotificationState { icon, progress, actions, + toast_only, } = spec; let is_loading = progress.is_some_and(|(current, total)| total == 0 || current < total); @@ -644,6 +661,7 @@ impl NotificationState { actions, tasks: Vec::new(), transfer: None, + toast_only, }, ); @@ -899,6 +917,7 @@ impl NotificationState { icon, progress: _, actions, + toast_only: _, } = spec; match entry_id.and_then(|id| inbox.iter_mut().find(|e| e.id == id)) { @@ -928,6 +947,7 @@ impl NotificationState { icon, progress: None, actions, + toast_only: false, }, ); } @@ -1084,4 +1104,62 @@ mod package_update_tests { assert!(wait.try_recv().is_err()); assert!(state.package_updates.is_some()); } + + fn spec(title: &str) -> NotificationSpec { + NotificationSpec { + title: title.to_string(), + body: String::new(), + level: Level::Info, + icon: None, + progress: None, + actions: Vec::new(), + toast_only: false, + } + } + + fn toast_only(title: &str) -> NotificationSpec { + NotificationSpec { + toast_only: true, + ..spec(title) + } + } + + #[test] + fn a_normal_notice_survives_its_toast_expiring() { + let mut state = NotificationState::default(); + let mut inbox = Vec::new(); + + let id = state.push_custom(&mut inbox, spec("kept")); + state.expire_toast(&mut inbox, id); + + assert!(inbox.iter().any(|e| e.id == id), "the inbox entry must stay"); + } + + #[test] + fn a_toast_only_notice_is_dropped_when_its_toast_expires() { + let mut state = NotificationState::default(); + let mut inbox = Vec::new(); + + let id = state.push_custom(&mut inbox, toast_only("gone")); + state.expire_toast(&mut inbox, id); + + assert!( + inbox.iter().all(|e| e.id != id), + "a toast-only notice must not linger in the inbox" + ); + } + + #[test] + fn a_toast_only_notice_is_dropped_when_its_toast_is_dismissed() { + let mut state = NotificationState::default(); + let mut inbox = Vec::new(); + + let id = state.push_custom(&mut inbox, toast_only("gone")); + state.dismiss_toast(&mut inbox, id); + + assert!( + inbox.iter().all(|e| e.id != id), + "a toast-only notice must not linger in the inbox" + ); + } } diff --git a/packages/oneclient_app/src/view/app/settings/java.rs b/packages/oneclient_app/src/view/app/settings/java.rs index 8c5b70e5..3e38aff0 100644 --- a/packages/oneclient_app/src/view/app/settings/java.rs +++ b/packages/oneclient_app/src/view/app/settings/java.rs @@ -6,6 +6,7 @@ use oneclient_java::{JavaRuntime, JavaVendor, is_launcher_managed}; use super::settings_page; use crate::components::{Button, Icon, IconType, JavaInstallManager, OverlayPopup, ScrollArea}; use crate::hooks::{Actions, java_runtimes, use_dispatch, use_java_runtimes}; +use crate::invalidate_java_queries; use crate::theme::colors; use crate::ui::border_all_color; use crate::view::app::settings::section_header; @@ -63,8 +64,9 @@ impl Component for AddRow { let dispatch = use_dispatch(); let mut show_manager = self.show_manager; + let pick_dispatch = dispatch.clone(); let pick = move |_| { - let dispatch = dispatch.clone(); + let dispatch = pick_dispatch.clone(); spawn(async move { if let Some(handle) = rfd::AsyncFileDialog::new() .set_title("Select a Java installation folder") @@ -76,6 +78,20 @@ impl Component for AddRow { }); }; + let refresh_dispatch = dispatch; + let refresh = move |_| { + let dispatch = refresh_dispatch.clone(); + spawn(async move { + invalidate_java_queries().await; + dispatch + .notify("Java runtimes refreshed") + .body("The installed runtime list is up to date") + .info() + .toast_only() + .send(); + }); + }; + rect() .horizontal() .width(Size::fill()) @@ -94,6 +110,13 @@ impl Component for AddRow { .child(Icon::new(IconType::Folder).size(14.)) .text("Add from folder"), ) + .child( + Button::new() + .secondary() + .on_press(refresh) + .child(Icon::new(IconType::RefreshCcw02).size(14.)) + .text("Refresh"), + ) } } diff --git a/packages/oneclient_core/src/images/mod.rs b/packages/oneclient_core/src/images/mod.rs index 6070240a..b253cee0 100644 --- a/packages/oneclient_core/src/images/mod.rs +++ b/packages/oneclient_core/src/images/mod.rs @@ -191,7 +191,10 @@ async fn download(net: &RequestClient, url: &str) -> LauncherResult { if !matches!(parsed.scheme(), "http" | "https") { return Err(refused(url, "unsupported scheme")); } - if !is_public_host(&parsed) { + // SSRF guard: refuse non-public hosts unless the user deliberately + // configured that host as the custom API endpoint / meta URL base, + // which is what makes `localhost` dev backends reachable + if !is_public_host(&parsed) && !net.config().allows_host(&parsed) { return Err(refused(url, "host is not public")); } diff --git a/packages/oneclient_net/src/config.rs b/packages/oneclient_net/src/config.rs index e2d9d7a4..f7d03ef0 100644 --- a/packages/oneclient_net/src/config.rs +++ b/packages/oneclient_net/src/config.rs @@ -45,13 +45,30 @@ impl NetConfig { self } - #[must_use] - pub fn modrinth_headers(&self) -> Vec<(String, String)> { - match &self.modrinth_api_key { - Some(token) => vec![("Authorization".to_string(), token.clone())], - None => Vec::new(), - } - } + #[must_use] + pub fn modrinth_headers(&self) -> Vec<(String, String)> { + match &self.modrinth_api_key { + Some(token) => vec![("Authorization".to_string(), token.clone())], + None => Vec::new(), + } + } + + /// Whether `url` targets a host the user explicitly configured as the + /// custom API endpoint or meta URL base. + #[must_use] + pub fn allows_host(&self, url: &reqwest::Url) -> bool { + let Some(host) = url.host_str() else { + return false; + }; + let port = url.port_or_known_default(); + + [self.metadata_api_url.as_str(), self.meta_url_base.as_str()] + .into_iter() + .filter_map(|base| base.parse::().ok()) + .any(|base| { + base.host_str() == Some(host) && base.port_or_known_default() == port + }) + } } fn non_empty(value: Option<&str>) -> Option { @@ -95,11 +112,49 @@ mod tests { None, ); - assert_eq!(config.curseforge_api_key, "key"); + assert_eq!(config.curseforge_api_key, "key"); assert_eq!( - config.modrinth_headers(), - vec![("Authorization".to_string(), "token".to_string())] + config.modrinth_headers(), + vec![("Authorization".to_string(), "token".to_string())] ); assert_eq!(config.metadata_api_url, "https://a"); + } + + fn parsed(url: &str) -> reqwest::Url { + url.parse().unwrap() + } + + #[test] + fn allows_host_matches_a_configured_local_endpoint() { + let config = NetConfig::default().with_overrides(None, None, None, Some("http://localhost:8000")); + + assert!(config.allows_host(&parsed("http://localhost:8000/icon.png"))); + assert!(config.allows_host(&parsed("http://localhost:8000/"))); + } + + #[test] + fn allows_host_requires_the_same_port() { + let config = NetConfig::default().with_overrides(None, None, None, Some("http://localhost:8000")); + + assert!( + !config.allows_host(&parsed("http://localhost:9090/icon.png")), + "a different port must not be treated as the configured endpoint" + ); + } + + #[test] + fn allows_host_ignores_hosts_that_were_not_configured() { + let config = NetConfig::default().with_overrides(None, None, Some("http://localhost:8000"), None); + + assert!(config.allows_host(&parsed("http://localhost:8000/icon.png"))); + assert!(!config.allows_host(&parsed("http://127.0.0.1:8000/icon.png"))); + assert!(!config.allows_host(&parsed("http://example.com/icon.png"))); + } + + #[test] + fn allows_host_is_false_when_no_custom_endpoint_is_set() { + let config = NetConfig::default(); + + assert!(!config.allows_host(&parsed("http://localhost:8000/icon.png"))); } }