fix(updater): check for updates on Linux deb/rpm installs and prompt manual download (#604)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-08-25 19:43:54 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 426c6d5eb6
commit 6777003b70
3 changed files with 272 additions and 31 deletions
+60 -14
View File
@@ -12,7 +12,7 @@ import type {
UpdateResponse, UpdateResponse,
YaakNotification, YaakNotification,
} from "@yaakapp-internal/tauri-client"; } 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 { openSettings } from "../commands/openSettings";
import { Button } from "../components/core/Button"; import { Button } from "../components/core/Button";
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading"; import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
@@ -180,9 +180,65 @@ function showUpdateInstalledToast(version: string) {
async function showUpdateAvailableToast(updateInfo: UpdateInfo) { async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
const UPDATE_TOAST_ID = "update-info"; 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 = (
<Button
size="xs"
color="info"
variant="border"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await platform.openUrl(`https://yaak.app/changelog/${version}`);
}}
>
What&apos;s New
</Button>
);
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: (
<VStack>
<h2 className="font-semibold">Yaak {version} is available</h2>
<p className="text-text-subtle text-sm">
{flatpak ? (
<>
Update with <InlineCode>flatpak update</InlineCode> or your software center.
</>
) : (
"Download the new version to upgrade."
)}
</p>
</VStack>
),
action: () => (
<HStack space={1.5}>
{!flatpak && (
<Button
size="xs"
color="info"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await platform.openUrl("https://yaak.app/download");
}}
>
Download
</Button>
)}
{whatsNewButton}
</HStack>
),
});
return;
}
// Acknowledge the event, so we don't time out and try the fallback update logic // Acknowledge the event, so we don't time out and try the fallback update logic
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse); await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
@@ -215,17 +271,7 @@ async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
> >
{downloaded ? "Install Now" : "Download and Install"} {downloaded ? "Install Now" : "Download and Install"}
</ButtonInfiniteLoading> </ButtonInfiniteLoading>
<Button {whatsNewButton}
size="xs"
color="info"
variant="border"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await platform.openUrl(`https://yaak.app/changelog/${version}`);
}}
>
What&apos;s New
</Button>
</HStack> </HStack>
), ),
}); });
+28 -6
View File
@@ -1,15 +1,37 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // 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<PluginUpdateInfo>, }; export type PluginUpdateNotification = { updateCount: number; plugins: Array<PluginUpdateInfo> };
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 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 };
+183 -10
View File
@@ -76,14 +76,6 @@ impl YaakUpdater {
auto_download: bool, auto_download: bool,
update_trigger: UpdateTrigger, update_trigger: UpdateTrigger,
) -> Result<bool> { ) -> Result<bool> {
// 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 settings = window.db().get_settings();
let update_key = format!("{:x}", md5::compute(settings.id)); let update_key = format!("{:x}", md5::compute(settings.id));
self.last_check = Some(Instant::now()); self.last_check = Some(Instant::now());
@@ -130,6 +122,18 @@ impl YaakUpdater {
Some(update) => { Some(update) => {
let w = window.clone(); let w = window.clone();
tauri::async_runtime::spawn(async move { 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) // Force native updater if specified (useful if a release broke the UI)
let native_install_mode = let native_install_mode =
update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default() update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default()
@@ -207,6 +211,23 @@ struct UpdateInfo {
reply_event_id: String, reply_event_id: String,
version: String, version: String,
downloaded: bool, 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)] #[derive(Debug, Clone, PartialEq, Deserialize, TS)]
@@ -272,8 +293,12 @@ async fn start_integrated_update<R: Runtime>(
let _guard = Unlisten { win: window, id: event_id }; let _guard = Unlisten { win: window, id: event_id };
// 2) Emit the event now that listener is in place // 2) Emit the event now that listener is in place
let info = let info = UpdateInfo {
UpdateInfo { version: update.version.to_string(), downloaded, reply_event_id: reply_id }; version: update.version.to_string(),
downloaded,
install: UpdateInstall::Integrated,
reply_event_id: reply_id,
};
window window
.emit_to(window.label(), "update_available", &info) .emit_to(window.label(), "update_available", &info)
.map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?; .map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?;
@@ -306,6 +331,24 @@ async fn start_integrated_update<R: Runtime>(
} }
} }
/// 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<R: Runtime>(
window: &WebviewWindow<R>,
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<R: Runtime>(window: &WebviewWindow<R>, update: &Update) { async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
// If the frontend doesn't respond, fallback to native dialogs // If the frontend doesn't respond, fallback to native dialogs
let confirmed = window let confirmed = window
@@ -376,7 +419,137 @@ fn detect_install_mode() -> Option<&'static str> {
return Some("nsis"); return Some("nsis");
} }
#[allow(unreachable_code)] #[allow(unreachable_code)]
if !cfg!(target_os = "linux") {
None 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 <current exe>` 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<R: Runtime>( pub async fn install_update_maybe_download<R: Runtime>(