diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs index 6d7e6c0f..668cf769 100644 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ b/packages/oneclient_app/src/bridge/runtime.rs @@ -250,6 +250,10 @@ impl CoreBridgeRuntime { }; refresh_settings(snapshots)?; bump_profiles_generation(snapshots); + + let auto_update = LauncherState::get()?.settings.read().auto_update; + crate::updater::spawn_update_check(auto_update); + Ok(()) } @@ -640,6 +644,7 @@ fn sync_notifications( let pending_view = pending.as_ref().map(|prompt| PendingPromptView { title: prompt.title.clone(), question: prompt.question.clone(), + kind: prompt.kind, }); snapshots.notifications = engine.snapshot(inbox, center_open, pending_view); } diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index fbb4f316..510c016b 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -26,6 +26,7 @@ mod tab_bar; mod text_input; mod toasts; mod toggle; +mod update_prompt; pub use active_cluster_panel::ActiveClusterPanel; pub use avatar::Avatar; @@ -57,3 +58,4 @@ pub use tab_bar::{TabBar, TabItem}; pub use text_input::{TextInput, validate_number}; pub use toasts::Toasts; pub use toggle::{toggle, toggle_controlled}; +pub use update_prompt::UpdatePromptOverlay; diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index 9db092ad..7b3ab333 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -18,24 +18,27 @@ impl Component for Navbar { rect() .width(Size::fill()) .height(Size::px(theme::NAVBAR_HEIGHT_PX)) - .horizontal() - .content(Content::Flex) - .cross_align(Alignment::Center) - .padding(Gaps::new_symmetric(0.0, 40.0)) - .child(navbar_left()) - .child(navbar_center()) - .child(NavbarRight) .position(Position::new_absolute().top(0.).left(0.)) .layer(Layer::RelativeOverlay(2)) - // TODO: Window Drag? - // .child( - // rect() - // .window_drag() - // .width(Size::flex(1.)) - // .height(Size::fill()) - // .position(Position::new_absolute()) - // .layer(Layer::RelativeOverlay(0)) - // ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .horizontal() + .content(Content::Flex) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(0.0, 40.0)) + .child(navbar_left()) + .child(navbar_center()) + .child(NavbarRight), + ) + .child( + rect() + .window_drag() + .width(Size::window_percent(100.)) + .height(Size::px(theme::NAVBAR_HEIGHT_PX)) + .position(Position::new_absolute().top(0.).left(0.).right(0.)) + ) } } @@ -92,6 +95,8 @@ struct NavLink { impl Component for NavLink { fn render(&self) -> impl IntoElement { let mut hovering = use_state(|| false); + let a11y_id = use_a11y(); + let focused = use_focus(a11y_id); let active = self.active; let target = self.target.clone(); @@ -101,7 +106,7 @@ impl Component for NavLink { Cursor::set(CursorIcon::default()); }); - let color = if active || *hovering.peek() { + let color = if active || *hovering.peek() || focused.peek().is_focused() { theme::colors::fg_primary() } else { theme::colors::fg_secondary() @@ -109,7 +114,7 @@ impl Component for NavLink { let underline_width = if active { 27. - } else if *hovering.peek() { + } else if *hovering.peek() || focused.peek().is_focused() { 18. } else { 0. @@ -120,7 +125,13 @@ impl Component for NavLink { .cross_align(Alignment::Center) .spacing(2.) .width(Size::px(nav_label.len() as f32 * 10. + 10.)) - .on_press(move |e: Event| { + // TODO: Hacky workaround for weird freya measurement bug, where if a rectangle has a transparent background (so in dec = 0), it will be measured weirdly. + // This basically fools freya into thinking it indeed has a box and properly handles pointer events + .background(Color::RED.with_a(0)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_all_press(move |e: Event| { e.prevent_default(); let _ = RouterContext::get().push(target.clone()); }) diff --git a/packages/oneclient_app/src/components/update_prompt.rs b/packages/oneclient_app/src/components/update_prompt.rs new file mode 100644 index 00000000..2c3071f7 --- /dev/null +++ b/packages/oneclient_app/src/components/update_prompt.rs @@ -0,0 +1,102 @@ +use freya::prelude::*; +use oneclient_core::notification::{PromptKind, UserChoice}; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); + +#[derive(PartialEq)] +pub struct UpdatePromptOverlay; + +impl Component for UpdatePromptOverlay { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + + let Some(prompt) = snapshot.pending_prompt.clone() else { + return rect().into_element(); + }; + + if prompt.kind != PromptKind::Update { + return rect().into_element(); + } + + let close = dispatch.clone(); + let cancel = dispatch.clone(); + let accept = dispatch.clone(); + + OverlayPopup::new() + .on_close(move |_| close.answer_prompt(UserChoice::Cancel)) + .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::DownloadCloud02) + .size(20.) + .color(colors::brand()), + ) + .child( + label() + .text(prompt.title.clone()) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(prompt.question.clone()) + .font_size(12.) + .max_lines(4) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| { + cancel.answer_prompt(UserChoice::Cancel) + }) + .text("Not now"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + accept.answer_prompt(UserChoice::Accept) + }) + .child(Icon::new(IconType::DownloadCloud02).size(14.)) + .text("Download"), + ), + ), + ), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 63f0922d..96a44da0 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -1,7 +1,7 @@ use freya::prelude::*; use freya::router::*; -use crate::components::{NotificationCenter, StatusBar, Toasts}; +use crate::components::{NotificationCenter, StatusBar, Toasts, UpdatePromptOverlay}; use crate::routes::Route; use crate::theme; use crate::theme::colors; @@ -27,6 +27,7 @@ impl Component for RootLayout { ) .child(NotificationCenter) .child(Toasts) + .child(UpdatePromptOverlay) .child(StatusBar) } } diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs index d026dc66..76bad9d8 100644 --- a/packages/oneclient_app/src/main.rs +++ b/packages/oneclient_app/src/main.rs @@ -46,8 +46,6 @@ fn main() { oneclient_core::logger::init() }.expect("Failed to initialize logger"); - oneclient_app::updater::spawn_update_check(settings.auto_update); - let (bridge, handle): (OneClientBridge, CoreBridgeHandle) = OneClientBridge::new(); handle.spawn_runtime(); diff --git a/packages/oneclient_app/src/notifications/mod.rs b/packages/oneclient_app/src/notifications/mod.rs index f9d0b0e5..19d515f9 100644 --- a/packages/oneclient_app/src/notifications/mod.rs +++ b/packages/oneclient_app/src/notifications/mod.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; use oneclient_core::notification::{ - GroupedProgressEvent, Notification, NotificationLevel, UserChoice, + GroupedProgressEvent, Notification, NotificationLevel, PromptKind, UserChoice, }; use tokio::sync::oneshot; use uuid::Uuid; @@ -65,6 +65,7 @@ pub struct ActiveToast { pub struct PendingPrompt { pub title: String, pub question: String, + pub kind: PromptKind, pub reply_tx: Option>, } @@ -72,6 +73,7 @@ pub struct PendingPrompt { pub struct PendingPromptView { pub title: String, pub question: String, + pub kind: PromptKind, } #[derive(Clone, Debug)] @@ -187,7 +189,7 @@ impl NotificationState { Notification::Prompt { title, question, - kind: _, + kind, reply_tx, } => { let entry_id = self.push_inbox( @@ -202,9 +204,14 @@ impl NotificationState { let pending_prompt = Some(PendingPrompt { title: title.clone(), question: question.clone(), + kind, reply_tx: Some(reply_tx), }); - let pending_view = Some(PendingPromptView { title, question }); + let pending_view = Some(PendingPromptView { + title, + question, + kind, + }); let timers = std::mem::take(&mut self.pending_timers); return ( self.snapshot(inbox, false, pending_view), diff --git a/packages/oneclient_app/src/updater.rs b/packages/oneclient_app/src/updater.rs index a6504f1f..ffc60e6e 100644 --- a/packages/oneclient_app/src/updater.rs +++ b/packages/oneclient_app/src/updater.rs @@ -1,24 +1,50 @@ -//! In-app self-update via [`cargo-packager-updater`]. +use std::cell::Cell; -use cargo_packager_updater::{Config, check_update}; +use cargo_packager_updater::{Config, Update, check_update}; +use oneclient_core::LauncherState; +use oneclient_core::notification::{NotificationService, UserChoice}; +use uuid::Uuid; pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFGODk3MkMyMjg0MjFDMDUKUldRRkhFSW93bktKSHpkWjNEMXNzaDVINVpCTU8xSnhuK2RnV0dTZ2FkcFJWbG1zUkhGYTNjaUkK"; pub const UPDATER_ENDPOINT: &str = "https://github.com/Polyfrost/OneLauncher/releases/latest/download/latest.json"; +const PROGRESS_STEP: u64 = 256 * 1024; + pub fn spawn_update_check(auto_install: bool) { - std::thread::Builder::new() - .name("oneclient-updater".into()) - .spawn(move || { - if let Err(err) = run_check(auto_install) { - tracing::warn!("update check failed: {err}"); - } - }) - .ok(); + tokio::spawn(async move { + if let Err(err) = run_check(auto_install).await { + tracing::warn!("update check failed: {err:#}"); + } + }); } -fn run_check(auto_install: bool) -> anyhow::Result<()> { +async fn run_check(auto_install: bool) -> anyhow::Result<()> { + let notifier = LauncherState::get()?.services.notifier.clone(); + + // `check_update` performs a blocking HTTP request, so offload it to a thread pool. + let Some(update) = tokio::task::spawn_blocking(check_for_update).await?? else { + tracing::info!("no update available"); + return Ok(()); + }; + + tracing::info!("update available: {}", update.version); + + if !auto_install { + match notifier.prompt_update(&update.version).await? { + UserChoice::Accept => {} + _ => { + tracing::info!("user declined update {}", update.version); + return Ok(()); + } + } + } + + download_and_install(update, notifier).await +} + +fn check_for_update() -> anyhow::Result> { let current = env!("CARGO_PKG_VERSION").parse()?; let config = Config { endpoints: vec![UPDATER_ENDPOINT.parse()?], @@ -26,17 +52,53 @@ fn run_check(auto_install: bool) -> anyhow::Result<()> { ..Default::default() }; - match check_update(current, config)? { - Some(update) => { - tracing::info!("update available: {}", update.version); - if auto_install { - tracing::info!("downloading and installing update…"); - update.download_and_install()?; - tracing::info!("update installed; restart to apply"); - } - } - None => tracing::info!("no update available"), - } + Ok(check_update(current, config)?) +} +async fn download_and_install( + update: Update, + notifier: NotificationService, +) -> anyhow::Result<()> { + let progress_id = Uuid::new_v4(); + let version = update.version.clone(); + let label = format!("Downloading OneClient {version}"); + + notifier.send_progress(&progress_id, &label, 0, 0); + + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + let downloaded = Cell::new(0u64); + let last_sent = Cell::new(0u64); + + let bytes = update.download_extended( + |chunk, total| { + let now = downloaded.get() + chunk as u64; + downloaded.set(now); + let total = total.unwrap_or(0); + + if now == chunk as u64 + || (total > 0 && now >= total) + || now - last_sent.get() >= PROGRESS_STEP + { + last_sent.set(now); + notifier.send_progress(&progress_id, &label, now, total); + } + }, + || {}, + )?; + + let total = downloaded.get().max(1); + notifier.send_progress(&progress_id, &label, total, total); + + update.install(bytes)?; + + notifier.send_info( + "Update ready", + &format!("OneClient {version} installed. Restart to apply."), + ); + Ok(()) + }) + .await??; + + tracing::info!("update installed; restart to apply"); Ok(()) } diff --git a/packages/oneclient_core/src/notification/data.rs b/packages/oneclient_core/src/notification/data.rs index b5bec4fa..46fab4a4 100644 --- a/packages/oneclient_core/src/notification/data.rs +++ b/packages/oneclient_core/src/notification/data.rs @@ -119,6 +119,7 @@ impl LaunchStage { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PromptKind { JavaInstall { major: u32 }, + Update, } #[derive(Debug)] diff --git a/packages/oneclient_core/src/notification/service.rs b/packages/oneclient_core/src/notification/service.rs index 10094277..a605cec9 100644 --- a/packages/oneclient_core/src/notification/service.rs +++ b/packages/oneclient_core/src/notification/service.rs @@ -51,6 +51,15 @@ impl NotificationService { .await } + pub async fn prompt_update(&self, version: &str) -> Result { + self.prompt( + "Update available", + &format!("OneClient {version} is ready to install. Download and install it now?"), + PromptKind::Update, + ) + .await + } + pub async fn prompt( &self, title: &str,