feat: add RPM builds, deduplicate .exe and .appimage and disable autoupdating on linux builds that aren't appimages

This commit is contained in:
LynithDev 2026-07-23 19:30:08 +02:00
parent 6fb841bc06
commit b3c8d4ab3a
9 changed files with 211 additions and 11 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Option<Update>> {
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(())
})

View file

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

View file

@ -71,6 +71,13 @@ fn spawn_notification_handler(mut rx: mpsc::UnboundedReceiver<Notification>) {
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));

View file

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

View file

@ -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<UserChoice, NotificationError> {
self.prompt(