From 6777003b702d43fe1dde849c7d918741dc7dbb2b Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 25 Aug 2026 19:43:54 -0700 Subject: [PATCH] fix(updater): check for updates on Linux deb/rpm installs and prompt manual download (#604) Co-authored-by: Claude Fable 5 --- apps/yaak-client/lib/initGlobalListeners.tsx | 74 +++++-- .../yaak-app-client/bindings/index.ts | 34 ++- crates-tauri/yaak-app-client/src/updates.rs | 195 +++++++++++++++++- 3 files changed, 272 insertions(+), 31 deletions(-) diff --git a/apps/yaak-client/lib/initGlobalListeners.tsx b/apps/yaak-client/lib/initGlobalListeners.tsx index 74ca698d..44863870 100644 --- a/apps/yaak-client/lib/initGlobalListeners.tsx +++ b/apps/yaak-client/lib/initGlobalListeners.tsx @@ -12,7 +12,7 @@ import type { UpdateResponse, YaakNotification, } from "@yaakapp-internal/tauri-client"; -import { HStack, Icon, VStack } from "@yaakapp-internal/ui"; +import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; import { openSettings } from "../commands/openSettings"; import { Button } from "../components/core/Button"; import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading"; @@ -180,9 +180,65 @@ function showUpdateInstalledToast(version: string) { async function showUpdateAvailableToast(updateInfo: UpdateInfo) { const UPDATE_TOAST_ID = "update-info"; - const { version, replyEventId, downloaded } = updateInfo; + const { version, replyEventId, downloaded, install } = updateInfo; - jotaiStore.set(updateAvailableAtom, { version, downloaded }); + jotaiStore.set(updateAvailableAtom, { version, downloaded, install }); + + const whatsNewButton = ( + + ); + + if (install !== "integrated") { + // Nothing to reply to here; the backend only told us so we can say how to update + const flatpak = install === "flatpak"; + showToast({ + id: UPDATE_TOAST_ID, + color: "info", + timeout: null, + message: ( + +

Yaak {version} is available

+

+ {flatpak ? ( + <> + Update with flatpak update or your software center. + + ) : ( + "Download the new version to upgrade." + )} +

+
+ ), + action: () => ( + + {!flatpak && ( + + )} + {whatsNewButton} + + ), + }); + return; + } // Acknowledge the event, so we don't time out and try the fallback update logic await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse); @@ -215,17 +271,7 @@ async function showUpdateAvailableToast(updateInfo: UpdateInfo) { > {downloaded ? "Install Now" : "Download and Install"} - + {whatsNewButton} ), }); diff --git a/crates-tauri/yaak-app-client/bindings/index.ts b/crates-tauri/yaak-app-client/bindings/index.ts index accd88f2..6fb02079 100644 --- a/crates-tauri/yaak-app-client/bindings/index.ts +++ b/crates-tauri/yaak-app-client/bindings/index.ts @@ -1,15 +1,37 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, }; +export type PluginUpdateInfo = { name: string; currentVersion: string; latestVersion: string }; -export type PluginUpdateNotification = { updateCount: number, plugins: Array, }; +export type PluginUpdateNotification = { updateCount: number; plugins: Array }; -export type UpdateInfo = { replyEventId: string, version: string, downloaded: boolean, }; +export type UpdateInfo = { + replyEventId: string; + version: string; + downloaded: boolean; + /** + * How this update gets applied. Anything but `Integrated` means the app can't do it + * itself and the user is told how to update instead. + */ + install: UpdateInstall; +}; -export type UpdateResponse = { "type": "ack" } | { "type": "action", action: UpdateResponseAction, }; +/** + * How an update can be applied to this install. + */ +export type UpdateInstall = "integrated" | "flatpak" | "manual"; + +export type UpdateResponse = { type: "ack" } | { type: "action"; action: UpdateResponseAction }; export type UpdateResponseAction = "install" | "skip"; -export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, }; +export type YaakNotification = { + timestamp: string; + timeout: number | null; + id: string; + title: string | null; + message: string; + color: string | null; + action: YaakNotificationAction | null; +}; -export type YaakNotificationAction = { label: string, url: string, }; +export type YaakNotificationAction = { label: string; url: string }; diff --git a/crates-tauri/yaak-app-client/src/updates.rs b/crates-tauri/yaak-app-client/src/updates.rs index e5a963fa..9f771607 100644 --- a/crates-tauri/yaak-app-client/src/updates.rs +++ b/crates-tauri/yaak-app-client/src/updates.rs @@ -76,14 +76,6 @@ impl YaakUpdater { auto_download: bool, update_trigger: UpdateTrigger, ) -> Result { - // Only AppImage supports updates on Linux, so skip if it's not - #[cfg(target_os = "linux")] - { - if std::env::var("APPIMAGE").is_err() { - return Ok(false); - } - } - let settings = window.db().get_settings(); let update_key = format!("{:x}", md5::compute(settings.id)); self.last_check = Some(Instant::now()); @@ -130,6 +122,18 @@ impl YaakUpdater { Some(update) => { let w = window.clone(); tauri::async_runtime::spawn(async move { + // Only hand the artifact to the updater plugin when this install can + // apply it itself; otherwise tell the user how to update instead + let install = update_install_method(&update); + if install != UpdateInstall::Integrated { + info!( + "{} available, but this install updates via {install:?}", + update.version + ); + notify_external_update(&w, &update, install); + return; + } + // Force native updater if specified (useful if a release broke the UI) let native_install_mode = update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default() @@ -207,6 +211,23 @@ struct UpdateInfo { reply_event_id: String, version: String, downloaded: bool, + /// How this update gets applied. Anything but `Integrated` means the app can't do it + /// itself and the user is told how to update instead. + install: UpdateInstall, +} + +/// How an update can be applied to this install. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Default, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export, export_to = "index.ts")] +enum UpdateInstall { + /// The app downloads and installs it itself + #[default] + Integrated, + /// Flatpak install: updated by `flatpak update` from its remote (e.g. FlatPark) + Flatpak, + /// Nothing can install it in-app (distro package, Nix, unknown); download by hand + Manual, } #[derive(Debug, Clone, PartialEq, Deserialize, TS)] @@ -272,8 +293,12 @@ async fn start_integrated_update( let _guard = Unlisten { win: window, id: event_id }; // 2) Emit the event now that listener is in place - let info = - UpdateInfo { version: update.version.to_string(), downloaded, reply_event_id: reply_id }; + let info = UpdateInfo { + version: update.version.to_string(), + downloaded, + install: UpdateInstall::Integrated, + reply_event_id: reply_id, + }; window .emit_to(window.label(), "update_available", &info) .map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?; @@ -306,6 +331,24 @@ async fn start_integrated_update( } } +/// Tell the frontend about an update this install can't apply itself, so the user can be +/// told how to get it. Unlike the integrated flow, there is nothing to reply to. +fn notify_external_update( + window: &WebviewWindow, + update: &Update, + install: UpdateInstall, +) { + let info = UpdateInfo { + version: update.version.to_string(), + downloaded: false, + install, + reply_event_id: generate_id(), + }; + if let Err(e) = window.emit_to(window.label(), "update_available", &info) { + warn!("Failed to emit update_available: {e}"); + } +} + async fn start_native_update(window: &WebviewWindow, update: &Update) { // If the frontend doesn't respond, fallback to native dialogs let confirmed = window @@ -376,7 +419,137 @@ fn detect_install_mode() -> Option<&'static str> { return Some("nsis"); } #[allow(unreachable_code)] - None + if !cfg!(target_os = "linux") { + None + } else if is_flatpak() { + Some("flatpak") + } else { + linux_installer() + } +} + +/// Flatpak installs (e.g. FlatPark) are updated by flatpak from their remote; the in-app +/// updater can't write inside the sandbox and must not try. +fn is_flatpak() -> bool { + std::env::var_os("FLATPAK_ID").is_some() +} + +/// How Yaak was installed on Linux, as far as the updater plugin can install into it. +/// +/// The bundle type is stamped into the binary by the bundler, but that only says how the +/// binary was *packaged*: third-party packages (AUR, Nix, ...) repackage the .deb, and +/// letting dpkg/rpm replace those would stomp on another package manager's files. So a +/// deb/rpm install also has to be one the package manager actually owns. The AppImage +/// updater needs `$APPIMAGE` since that's the file it replaces. +fn linux_installer() -> Option<&'static str> { + use tauri::utils::{config::BundleType, platform::bundle_type}; + match bundle_type() { + Some(BundleType::Deb) if package_manager_owns_exe("dpkg", "-S") => Some("deb"), + Some(BundleType::Rpm) if package_manager_owns_exe("rpm", "-qf") => Some("rpm"), + Some(BundleType::Deb) | Some(BundleType::Rpm) => None, + _ if std::env::var_os("APPIMAGE").is_some() => Some("appimage"), + _ => None, + } +} + +/// Whether `cmd query_arg ` succeeds, i.e. that package manager knows the +/// running executable as one of its files. False when the tool isn't installed at all. +fn package_manager_owns_exe(cmd: &str, query_arg: &str) -> bool { + let Ok(exe) = std::env::current_exe() else { + return false; + }; + std::process::Command::new(cmd) + .arg(query_arg) + .arg(&exe) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +/// How the artifact the server returned can be applied to this install. On Linux the +/// server may hand back a different package format than the one installed, Flatpak can't +/// be written from inside the sandbox, and unknown install methods (distro packages, +/// Nix, ...) can't be updated in-app at all. +fn update_install_method(update: &Update) -> UpdateInstall { + // Dev-only override to preview the non-integrated flows on any OS: + // YAAK_SIMULATE_INSTALL=flatpak|manual + if is_dev() { + match std::env::var("YAAK_SIMULATE_INSTALL").as_deref() { + Ok("flatpak") => return UpdateInstall::Flatpak, + Ok("manual") => return UpdateInstall::Manual, + _ => {} + } + } + + if !cfg!(target_os = "linux") { + return UpdateInstall::Integrated; + } + if is_flatpak() { + return UpdateInstall::Flatpak; + } + if artifact_matches_installer(linux_installer(), update.download_url.path()) { + UpdateInstall::Integrated + } else { + UpdateInstall::Manual + } +} + +/// Whether the artifact at `url_path` is in the package format `installer` can install. +fn artifact_matches_installer(installer: Option<&str>, url_path: &str) -> bool { + let path = url_path.to_ascii_lowercase(); + match installer { + Some("deb") => path.ends_with(".deb"), + Some("rpm") => path.ends_with(".rpm"), + Some("appimage") => path.ends_with(".appimage") || path.ends_with(".appimage.tar.gz"), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::artifact_matches_installer; + + const BASE: &str = "/mountain-loop/yaak/releases/download/v2026.6.0/"; + + #[test] + fn matching_package_format_is_installable() { + let cases = [ + ("deb", "yaak_2026.6.0_amd64.deb"), + ("deb", "yaak_2026.6.0_arm64.deb"), + ("rpm", "yaak-2026.6.0-1.x86_64.rpm"), + ("rpm", "yaak-2026.6.0-1.aarch64.rpm"), + ("appimage", "yaak_2026.6.0_amd64.AppImage"), + ("appimage", "yaak_2026.6.0_amd64.AppImage.tar.gz"), + ]; + for (installer, asset) in cases { + assert!( + artifact_matches_installer(Some(installer), &format!("{BASE}{asset}")), + "{installer} should install {asset}" + ); + } + } + + #[test] + fn mismatched_package_format_is_not_installable() { + // What the server returns for every Linux install today + let appimage = format!("{BASE}yaak_2026.6.0_amd64.AppImage"); + assert!(!artifact_matches_installer(Some("deb"), &appimage)); + assert!(!artifact_matches_installer(Some("rpm"), &appimage)); + + let deb = format!("{BASE}yaak_2026.6.0_amd64.deb"); + assert!(!artifact_matches_installer(Some("rpm"), &deb)); + assert!(!artifact_matches_installer(Some("appimage"), &deb)); + } + + #[test] + fn unknown_installer_is_never_installable() { + for asset in ["yaak_2026.6.0_amd64.deb", "yaak_2026.6.0_amd64.AppImage"] { + assert!(!artifact_matches_installer(None, &format!("{BASE}{asset}"))); + } + } } pub async fn install_update_maybe_download(