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>
This commit is contained in:
sakuyma 2026-09-18 21:43:49 +00:00 • committed by GitHub
parent daaa209b30
commit 45a970c38a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 272 additions and 25 deletions

View file

@ -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

View file

@ -48,9 +48,13 @@ impl Component for NotificationPanel {
let progress = intro.read().value();
let entries = inbox.len();
let mut rows: Vec<Element> = Vec::new();
if entries == 0 {
// Toast-only notices are ephemeral: never surface them in the center
let visible: Vec<InboxEntry> = 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());
}

View file

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

View file

@ -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<String>) {
@ -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);
}

View file

@ -165,6 +165,7 @@ pub async fn cluster_update_notification(
label: "View changes".to_string(),
kind: NotificationActionKind::OpenClusterUpdate(vec![summary]),
}],
toast_only: false,
})
}

View file

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

View file

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

View file

@ -119,6 +119,10 @@ pub struct InboxEntry {
pub tasks: Vec<TaskView>,
/// Bytes/sec and seconds remaining for grouped downloads
pub transfer: Option<TransferStats>,
/// 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<IconType>,
pub progress: Option<(u64, u64)>,
pub actions: Vec<NotificationAction>,
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<InboxEntry>, 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"
);
}
}

View file

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

View file

@ -191,7 +191,10 @@ async fn download(net: &RequestClient, url: &str) -> LauncherResult<Bytes> {
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"));
}

View file

@ -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::<reqwest::Url>().ok())
.any(|base| {
base.host_str() == Some(host) && base.port_or_known_default() == port
})
}
}
fn non_empty(value: Option<&str>) -> Option<String> {
@ -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")));
}
}