mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-27 13:54:11 +02:00
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:
co-authored by
Claude Fable 5
parent
426c6d5eb6
commit
6777003b70
@@ -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'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's New
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
+28
-6
@@ -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 };
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
Reference in New Issue
Block a user