mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-27 05:44:02 +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
+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.
|
||||
|
||||
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 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,
|
||||
update_trigger: UpdateTrigger,
|
||||
) -> 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 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<R: Runtime>(
|
||||
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<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) {
|
||||
// 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 <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>(
|
||||
|
||||
Reference in New Issue
Block a user