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:
parent
daaa209b30
commit
45a970c38a
11 changed files with 272 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ pub async fn cluster_update_notification(
|
|||
label: "View changes".to_string(),
|
||||
kind: NotificationActionKind::OpenClusterUpdate(vec![summary]),
|
||||
}],
|
||||
toast_only: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue