diff --git a/.github/workflows/oneclient_nightly.yml b/.github/workflows/oneclient_nightly.yml index 3cb5d08f..b1fef7eb 100644 --- a/.github/workflows/oneclient_nightly.yml +++ b/.github/workflows/oneclient_nightly.yml @@ -32,7 +32,7 @@ jobs: target: '' os_key: linux arch: x86_64 - formats: deb,appimage + formats: deb,rpm,appimage - platform: windows-latest target: '' os_key: windows @@ -127,6 +127,7 @@ jobs: # (no updater manifest is produced for unsigned nightly builds). for f in "${{ steps.outdir.outputs.dir }}"/*.dmg \ "${{ steps.outdir.outputs.dir }}"/*.deb \ + "${{ steps.outdir.outputs.dir }}"/*.rpm \ "${{ steps.outdir.outputs.dir }}"/*.AppImage \ "${{ steps.outdir.outputs.dir }}"/*-setup.exe; do [ -e "$f" ] || continue diff --git a/.github/workflows/oneclient_release.yml b/.github/workflows/oneclient_release.yml index 3979b3b6..9e38b625 100644 --- a/.github/workflows/oneclient_release.yml +++ b/.github/workflows/oneclient_release.yml @@ -65,7 +65,7 @@ jobs: target: '' os_key: linux arch: x86_64 - formats: deb,appimage + formats: deb,rpm,appimage - platform: windows-latest target: '' os_key: windows @@ -273,9 +273,22 @@ jobs: cp "${deb_matches[0]}" "$OUT_DIR/$deb_unique" fi - # Upload human-facing installers (dmg/renamed deb/AppImage/signed exe) + the renamed - # updater bundle and its .sig. Skip the un-renamed updater bundle to avoid clashes. - for f in "$OUT_DIR"/*.dmg ${deb_unique:+"$OUT_DIR/$deb_unique"} "$OUT_DIR"/*.AppImage "$OUT_DIR"/*-setup.exe "$OUT_DIR/$unique" "$OUT_DIR/$unique.sig"; do + # Same treatment for the .rpm (cargo-packager names it + # oneclient_app--1.x86_64.rpm). + rpm_unique="" + rpm_matches=("$OUT_DIR"/*.rpm) + if [ -e "${rpm_matches[0]:-}" ]; then + rpm_unique="OneClient_${VERSION}_${OS_KEY}_${ARCH}.rpm" + cp "${rpm_matches[0]}" "$OUT_DIR/$rpm_unique" + fi + + # Upload human-facing installers + the renamed updater bundle and its .sig. + # For linux/windows the updater artifact IS the installer (AppImage/exe), so the + # renamed $unique copy is the only one uploaded — do NOT also glob the un-renamed + # *.AppImage / *-setup.exe original, or the release ends up with two AppImages / two + # setup exes (one named oneclient_app_*, one OneClient_*). For darwin the human + # installer (.dmg) is separate from the .app.tar.gz updater bundle, so both go up. + for f in "$OUT_DIR"/*.dmg ${deb_unique:+"$OUT_DIR/$deb_unique"} ${rpm_unique:+"$OUT_DIR/$rpm_unique"} "$OUT_DIR/$unique" "$OUT_DIR/$unique.sig"; do [ -e "$f" ] || continue gh release upload "$TAG" "$f" --clobber done diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml index 23bc74b4..b7824e45 100644 --- a/packages/oneclient_app/Cargo.toml +++ b/packages/oneclient_app/Cargo.toml @@ -58,7 +58,7 @@ publisher = "Polyfrost" copyright = "© 2026 Polyfrost Inc." homepage = "https://polyfrost.org/projects/oneclient" long-description = "Next-generation open source Minecraft launcher" -formats = ["nsis", "app", "dmg", "deb", "appimage"] +formats = ["nsis", "app", "dmg", "deb", "rpm", "appimage"] binaries = [{ path = "oneclient_app", main = true }] icons = [ "icons/32x32.png", @@ -122,6 +122,9 @@ SectionEnd [package.metadata.packager.deb] desktop-template = "distribution/linux/org.polyfrost.oneclient.template" +[package.metadata.packager.rpm] +desktop-template = "distribution/linux/org.polyfrost.oneclient.template" + [package.metadata.packager.appimage] # desktop entry generated from the deb template above via the linux config diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index 8e29796f..9d46c0db 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -317,6 +317,9 @@ impl NotificationState { } => { self.handle_progress(inbox, id, label, current, total); } + Notification::ProgressComplete { id, title, body } => { + self.handle_progress_complete(inbox, id, title, body); + } Notification::GroupedProgress(event) => { self.handle_grouped_progress(inbox, event); } @@ -572,8 +575,29 @@ impl NotificationState { self.update_inbox_entry(inbox, entry_id, label, body, progress, !done); self.ensure_progress_toast(entry_id); - if done { - self.progress_entries.remove(&id); + // NOTE: the id→entry mapping is intentionally kept even once `done`, so a + // later `ProgressComplete` can convert this same card into its finished state + // (see `handle_progress_complete`). The mapping is cleaned up when the entry is + // dismissed or the engine is reset. + } + + /// Convert an in-flight progress card into a finished message in place. Used so an + /// operation and its "done" notice stay a single notification. Falls back to a fresh + /// message if the progress entry is already gone (e.g. dismissed mid-flight). + fn handle_progress_complete( + &mut self, + inbox: &mut Vec, + id: Uuid, + title: String, + body: String, + ) { + if let Some(entry_id) = self.progress_entries.remove(&id) { + self.update_inbox_entry(inbox, entry_id, title, body, None, false); + self.ensure_progress_toast(entry_id); + } else { + let entry_id = + self.push_inbox(inbox, title, body, NotificationLevel::Info, None, false); + self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); } } diff --git a/packages/oneclient_app/src/updater.rs b/packages/oneclient_app/src/updater.rs index 69c07c5a..fc5f9a74 100644 --- a/packages/oneclient_app/src/updater.rs +++ b/packages/oneclient_app/src/updater.rs @@ -10,6 +10,8 @@ pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYy pub const UPDATER_ENDPOINT: &str = "https://github.com/Polyfrost/OneLauncher/releases/latest/download/latest.json"; +pub const RELEASES_URL: &str = "https://github.com/Polyfrost/OneLauncher/releases/latest"; + const PROGRESS_STEP: u64 = 256 * 1024; pub fn spawn_update_check(auto_install: bool) { @@ -20,6 +22,52 @@ pub fn spawn_update_check(auto_install: bool) { }); } +/// Debug-only: drive the full auto-update UX — prompt, download progress, then the +/// "restart to apply" notification — against a fake release, without hitting the +/// network or touching disk. Mirrors [`run_check`] + [`download_and_install`] so the +/// debug page exercises the real notification path. +pub fn spawn_simulated_update() { + tokio::spawn(async move { + if let Err(err) = run_simulated_update().await { + tracing::warn!("simulated update failed: {err:#}"); + } + }); +} + +async fn run_simulated_update() -> anyhow::Result<()> { + const FAKE_VERSION: &str = "9.9.9"; + const FAKE_TOTAL: u64 = 48 * 1024 * 1024; // arbitrary 48 MiB "download" + + let notifier = LauncherState::get()?.services.notifier.clone(); + + match notifier.prompt_update(FAKE_VERSION).await? { + UserChoice::Accept => {} + _ => { + tracing::info!("user declined simulated update"); + return Ok(()); + } + } + + let progress_id = Uuid::new_v4(); + let label = format!("Downloading OneClient {FAKE_VERSION}"); + + let mut downloaded = 0u64; + notifier.send_progress(&progress_id, &label, downloaded, FAKE_TOTAL); + while downloaded < FAKE_TOTAL { + downloaded = (downloaded + PROGRESS_STEP * 8).min(FAKE_TOTAL); + notifier.send_progress(&progress_id, &label, downloaded, FAKE_TOTAL); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + notifier.finish_progress( + &progress_id, + "Finished Downloading", + &format!("OneClient {FAKE_VERSION} is ready — restart to apply."), + ); + + Ok(()) +} + async fn run_check(auto_install: bool) -> anyhow::Result<()> { let notifier = LauncherState::get()?.services.notifier.clone(); @@ -31,6 +79,24 @@ async fn run_check(auto_install: bool) -> anyhow::Result<()> { tracing::info!("update available: {}", update.version); + // The bundled self-updater can only replace an AppImage in place: on Linux + // cargo-packager-updater rejects every format except AppImage and locates the + // target via the `APPIMAGE` env var. deb/rpm installs run from a package-managed + // path (e.g. /usr/bin) we can't overwrite without root — attempting an install + // would fail with a permission error, or clobber the system binary if elevated. + // Detect that case and point the user at the release page instead. + if !can_self_update() { + tracing::info!("install is not self-updatable (non-AppImage Linux); notifying only"); + notifier.send_info( + "Update available", + &format!( + "OneClient {} is available. Download the latest package from {} to update.", + update.version, RELEASES_URL + ), + ); + return Ok(()); + } + if !auto_install { match notifier.prompt_update(&update.version).await? { UserChoice::Accept => {} @@ -44,6 +110,23 @@ async fn run_check(auto_install: bool) -> anyhow::Result<()> { download_and_install(update, notifier).await } +/// Whether the running install can be updated in place by the bundled updater. +/// +/// Windows (NSIS) and macOS (.app) installs are always self-updatable. On Linux +/// only AppImage installs are: cargo-packager-updater replaces the file named by +/// the `APPIMAGE` env var and rejects every other format, so deb/rpm installs — +/// which run from a package-manager-owned path and never set `APPIMAGE` — are not. +fn can_self_update() -> bool { + #[cfg(target_os = "linux")] + { + std::env::var_os("APPIMAGE").is_some() + } + #[cfg(not(target_os = "linux"))] + { + true + } +} + fn check_for_update() -> anyhow::Result> { let current = env!("CARGO_PKG_VERSION").parse()?; let config = Config { @@ -88,9 +171,12 @@ async fn download_and_install(update: Update, notifier: NotificationService) -> update.install(bytes)?; - notifier.send_info( - "Update ready", - &format!("OneClient {version} installed. Restart to apply."), + // Convert the same download card into its finished state instead of emitting a + // second notification, so the user sees one notification through to completion. + notifier.finish_progress( + &progress_id, + "Finished Downloading", + &format!("OneClient {version} is ready — restart to apply."), ); Ok(()) }) diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index ca73baf8..5020f908 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -72,6 +72,11 @@ impl Component for Debug { vec![ClusterUpdateSimulator.into_element()], )) .child(divider()) + .child(section( + "Launcher Auto Update", + vec![LauncherUpdateSimulator.into_element()], + )) + .child(divider()) .child(section( "Auth Error Guidance", vec![AuthGuidancePreview.into_element()], @@ -279,6 +284,45 @@ impl Component for ClusterUpdateSimulator { } } +#[derive(PartialEq)] +struct LauncherUpdateSimulator; + +impl Component for LauncherUpdateSimulator { + fn render(&self) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Drives the real auto-update UX (prompt → download progress → \"restart to apply\") against a fake release — no network or disk. \"Check for Updates Now\" runs the real check against the release feed.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::DownloadCloud02).size(16.)) + .text("Simulate Auto Update") + .on_press(|_| crate::updater::spawn_simulated_update()), + ) + .child( + Button::new() + .secondary() + .child(Icon::new(IconType::RefreshCw01).size(16.)) + .text("Check for Updates Now") + .on_press(|_| crate::updater::spawn_update_check(false)), + ), + ) + .into_element() + } +} + #[derive(PartialEq)] struct AuthGuidancePreview; diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs index 4ea18906..fdd9341e 100644 --- a/packages/oneclient_core/src/dev.rs +++ b/packages/oneclient_core/src/dev.rs @@ -71,6 +71,13 @@ fn spawn_notification_handler(mut rx: mpsc::UnboundedReceiver) { pb.set_position(current); } } + Notification::ProgressComplete { id, title, body } => { + if let Some(pb) = progress_bars.remove(&id) { + pb.finish_with_message(format!("{title}: {body}")); + } else { + mp.suspend(|| tracing::info!(%title, %body)); + } + } Notification::GroupedProgress(event) => match event { GroupedProgressEvent::Start { session_id, title } => { let parent = mp.add(ProgressBar::new(1)); diff --git a/packages/oneclient_core/src/notification/data.rs b/packages/oneclient_core/src/notification/data.rs index 80025d7b..1f0f2b39 100644 --- a/packages/oneclient_core/src/notification/data.rs +++ b/packages/oneclient_core/src/notification/data.rs @@ -111,6 +111,15 @@ pub enum Notification { total: u64, }, + /// Convert an in-flight [`Notification::Progress`] entry (matched by `id`) into a + /// finished message in place, so a download and its completion notice are a single + /// notification instead of two separate cards. + ProgressComplete { + id: Uuid, + title: String, + body: String, + }, + GroupedProgress(GroupedProgressEvent), GameStage { diff --git a/packages/oneclient_core/src/notification/service.rs b/packages/oneclient_core/src/notification/service.rs index 503daf7d..2faffabe 100644 --- a/packages/oneclient_core/src/notification/service.rs +++ b/packages/oneclient_core/src/notification/service.rs @@ -41,6 +41,19 @@ impl NotificationService { } } + /// Finish an in-flight progress notification (identified by the same `id` passed to + /// [`send_progress`](Self::send_progress)) by converting its card into a completed + /// message in place, rather than emitting a separate follow-up notification. + pub fn finish_progress(&self, id: &Uuid, title: &str, body: &str) { + if let Err(err) = self.channel.send(Notification::ProgressComplete { + id: *id, + title: title.to_string(), + body: body.to_string(), + }) { + tracing::error!("{err}"); + } + } + #[tracing::instrument(level = "debug", skip(self))] pub async fn prompt_java_install(&self, major: u32) -> Result { self.prompt(