From cdda2bc45f3c1e80ad2392df6f78bfd9e082021f Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sat, 31 Oct 2026 10:13:56 -0300 Subject: [PATCH] feat(update): one-click desktop self-update (AppImage, .deb, Windows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_apply used to flatly refuse to run for the GUI binary and point the user to a manual download instead. Since both desktop apps already elevate to root/administrator before showing their window, the process applying the update already has everything it needs — no second authentication round is required. - New guara_core::update::apply_desktop[_with_progress], alongside the existing CLI-only apply() (left untouched): detects the install kind at runtime (AppImage via the APPIMAGE env var — current_exe() is unreliable there, same FUSE-mount issue elevated_exe() already works around and this reuses; Deb otherwise on Linux; WindowsInstaller on Windows), matches the right release asset by the GUI bundle naming (_amd64/_aarch64.AppImage, _amd64/_arm64.deb, _x64-setup.exe — a different suffix convention than the CLI tarballs), verifies SHA-256 when a sidecar is published, and installs: - AppImage: downloads to a temp file in the same directory (same filesystem, atomic rename), renames over the running file, execs it in place (same PID, same already-root privileges — no elevation dance needed). - .deb: runs `dpkg -i` directly (already root), then execs current_exe() again now that dpkg replaced it on disk. - Windows: runs the downloaded installer with /S (silent) detached, then exits immediately to release the file lock on our own .exe. Does NOT claim to relaunch the app afterwards — there is no documented NSIS or Tauri flag for that (verified against NSIS's and Tauri's own docs: Tauri's own updater restarts the app from its own Rust code after the installer exits, not via a command-line switch), and blocking here on the installer's exit status risks a self-deadlock if its script waits for this process to close before it can overwrite the running .exe. Needs a real Windows machine to confirm the actual post-install behavior. - Engine::update_apply_desktop wires it into the Tauri `update_apply` command, which now also takes AppHandle and emits `update-progress` events (downloading/verifying/installing/restarting) for the UI. - Sabiá: the existing #btnUpdate handler changed the button's label to "update to X" but never actually called apply — wired it for real, with the same progress feedback. - Guará: restored the "Update now" action in the update card (removed during the updater-unification migration), wired to the new command. - scripts/build-gui.sh: generates a .sha256 next to every file in dist-gui/, matching the existing convention for CLI release archives, so apply_desktop can verify downloads the same way CLI update --apply already does. - 3 new unit tests per repo: install-kind detection, GUI asset suffix matching against real release naming, and the existing CLI-refusal test still passes unchanged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD --- apps/gui/src-tauri/src/main.rs | 15 +- apps/gui/ui/app.js | 48 ++++- apps/gui/ui/i18n-en.js | 7 +- crates/guara-core/src/service.rs | 11 + crates/guara-core/src/update.rs | 337 ++++++++++++++++++++++++++++++- scripts/build-gui.sh | 13 ++ 6 files changed, 424 insertions(+), 7 deletions(-) diff --git a/apps/gui/src-tauri/src/main.rs b/apps/gui/src-tauri/src/main.rs index aff2e7f..e78877b 100644 --- a/apps/gui/src-tauri/src/main.rs +++ b/apps/gui/src-tauri/src/main.rs @@ -28,8 +28,19 @@ fn update_check(state: tauri::State<'_, State>) -> Result) -> Result { - state.0.update_apply().map_err(|e| e.to_string()) +fn update_apply(app: tauri::AppHandle, state: tauri::State<'_, State>) -> Result { + use tauri::Emitter; + #[cfg(any(target_os = "linux", target_os = "macos"))] + let exe = elevated_exe().unwrap_or_else(|_| std::env::current_exe().unwrap_or_default()); + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let exe = std::env::current_exe().unwrap_or_default(); + + state + .0 + .update_apply_desktop(&exe, move |phase| { + let _ = app.emit("update-progress", serde_json::json!({ "phase": phase })); + }) + .map_err(|e| e.to_string()) } /// `true` when the process already has sufficient privileges to read raw disks. diff --git a/apps/gui/ui/app.js b/apps/gui/ui/app.js index c58ad55..7ec58f7 100644 --- a/apps/gui/ui/app.js +++ b/apps/gui/ui/app.js @@ -750,7 +750,11 @@ $('#settingsBtn')?.addEventListener('click', () => { ${tauri?.core?.invoke ? `` : ''} `; d.querySelector('[name=lang]').value = SETTINGS.lang; - d.querySelector('#updNow')?.addEventListener('click', (e) => { e.preventDefault(); checkUpdate(true); }); + d.querySelector('#updNow')?.addEventListener('click', (e) => { + e.preventDefault(); + if (UPD) applyUpdate(); + else checkUpdate(true); + }); d.onclose = () => { if (d.returnValue !== 'ok') return; const patch = { lang: d.querySelector('[name=lang]').value, notify: d.querySelector('[name=notify]').checked, maps: d.querySelector('[name=maps]').checked }; @@ -768,6 +772,44 @@ $('#settingsBtn')?.addEventListener('click', () => { // The desktop shell (Tauri) queries Gitea Releases API and notifies the user. // No-op in browser mode (`guara ui`). let UPD = null; +let updating = false; + +async function applyUpdate() { + if (updating || !tauri?.core?.invoke) return; + updating = true; + const updApply = $('#updApply'); + const updNow = $('#updNow'); + const setPhase = (txt) => { + if (updApply) { updApply.disabled = true; updApply.textContent = txt; } + if (updNow) { updNow.disabled = true; updNow.textContent = txt; } + }; + setPhase('Baixando a atualização…'); + + let unlisten = null; + if (tauri?.event?.listen) { + try { + unlisten = await tauri.event.listen('update-progress', (event) => { + const phase = event.payload?.phase; + if (phase === 'downloading') setPhase('Baixando a atualização…'); + else if (phase === 'verifying') setPhase('Verificando integridade…'); + else if (phase === 'installing') setPhase('Instalando a atualização…'); + else if (phase === 'restarting') setPhase('Reiniciando…'); + }); + } catch (_) {} + } + + try { + await tauri.core.invoke('update_apply'); + setPhase('Atualização concluída'); + } catch (e) { + updating = false; + if (updApply) { updApply.disabled = false; updApply.textContent = 'Atualizar agora'; } + if (updNow) { updNow.disabled = false; updNow.textContent = UPD?.version ? `atualizar para ${UPD.version}` : 'Verificar agora'; } + toast(`Falha na atualização: ${e}`); + } finally { + if (unlisten) unlisten(); + } +} async function checkUpdate(manual) { if (!tauri?.core?.invoke) return; @@ -801,11 +843,13 @@ function renderUpdateCard() { if (!UPD) { el?.remove(); return; } if (!el) { el = document.createElement('div'); el.id = 'updateCard'; el.className = 'update-card'; el.setAttribute('role', 'status'); document.body.appendChild(el); } el.innerHTML = `
Nova versão do Guará disponívelv${esc(UPD.current)} → v${esc(UPD.version)}
-

Esta instalação vem de um pacote do sistema e não se atualiza sozinha. Baixe a versão nova na página da release:

${esc(UPD.url)}

+

Instalação automática da nova versão com um clique:

+
`; $('#updLater')?.addEventListener('click', () => { UPD = null; el.remove(); }); + $('#updApply')?.addEventListener('click', () => applyUpdate()); } // ------------------------------------------------------------------ close while scan is running diff --git a/apps/gui/ui/i18n-en.js b/apps/gui/ui/i18n-en.js index 2d4dc67..2355d1c 100644 --- a/apps/gui/ui/i18n-en.js +++ b/apps/gui/ui/i18n-en.js @@ -582,7 +582,7 @@ export const EN = { 'Atualizando o Guará…': 'Updating Guará…', 'Baixando a atualização…': 'Downloading the update…', 'Baixando… {n}%': 'Downloading… {n}%', - 'Instalando… o Guará vai reabrir sozinho.': 'Installing… Guará will reopen by itself.', + 'Instalando a atualização…': 'Installing the update…', 'Esta instalação vem de um pacote do sistema e não se atualiza sozinha. Baixe a versão nova na página da release:': 'This install comes from a system package and cannot update itself. Download the new version from the release page:', 'Atualizações automáticas': 'Automatic updates', 'Ao abrir, consulta o Gitea para ver se há versão nova (uma consulta, sem enviar dados seus)': 'On startup, checks Gitea for a new version (one request, nothing of yours is sent)', @@ -593,4 +593,9 @@ export const EN = { 'Nova versão {n} disponível': 'New version {n} available', 'Você já está na versão mais recente': 'You are already on the latest version', 'Não foi possível verificar atualizações: {n}': 'Could not check for updates: {n}', + 'Instalação automática da nova versão com um clique:': 'One-click automatic installation of the new version:', + 'Verificando integridade…': 'Verifying checksum…', + 'Reiniciando…': 'Restarting…', + 'Atualização concluída': 'Update completed', + 'Falha na atualização: {n}': 'Update failed: {n}', }; diff --git a/crates/guara-core/src/service.rs b/crates/guara-core/src/service.rs index fb63b5d..67c4863 100644 --- a/crates/guara-core/src/service.rs +++ b/crates/guara-core/src/service.rs @@ -1323,6 +1323,17 @@ impl Engine { let exe = std::env::current_exe()?; crate::update::apply(&exe) } + + pub fn update_apply_desktop( + &self, + current_exe: &std::path::Path, + on_progress: F, + ) -> Result + where + F: Fn(&str), + { + crate::update::apply_desktop_with_progress(current_exe, on_progress) + } } pub use crate::explorer::Query; diff --git a/crates/guara-core/src/update.rs b/crates/guara-core/src/update.rs index 7f8824a..2f464a9 100644 --- a/crates/guara-core/src/update.rs +++ b/crates/guara-core/src/update.rs @@ -9,7 +9,7 @@ use crate::error::{Error, Result}; use serde::Deserialize; use sha2::{Digest, Sha256}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; /// Gitea `releases/latest` API endpoint (`tag_name`, `assets[].name`, `assets[].browser_download_url`). @@ -52,7 +52,7 @@ fn curl_text(url: &str) -> Result { Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } -fn curl_download(url: &str, dest: &PathBuf) -> Result<()> { +fn curl_download(url: &str, dest: &Path) -> Result<()> { let status = Command::new("curl") .args(["-fsSL", "-H", "User-Agent: guara-updater", "-o"]) .arg(dest) @@ -223,6 +223,273 @@ fn find_binary(dir: &PathBuf) -> Result { Err(Error::msg("binário não encontrado no pacote baixado")) } +/// Desktop GUI installation type detected at runtime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DesktopInstall { + /// Linux AppImage: real filesystem path of the .AppImage file (from APPIMAGE env var). + AppImage(PathBuf), + /// Linux Debian package installed via dpkg: `current_exe` is reliable here. + Deb, + /// Windows NSIS installer: downloaded setup executable run with `/S` + /// (silent). Does not relaunch the app on its own — see the comment on + /// the `WindowsInstaller` apply path. + WindowsInstaller, +} + +/// Detects the desktop installation type given an optional `APPIMAGE` environment +/// variable value and the current executable path. +pub fn detect_desktop_install_from( + appimage_env: Option<&std::ffi::OsStr>, + current_exe: &Path, +) -> Result { + #[cfg(windows)] + { + let _ = (appimage_env, current_exe); + Ok(DesktopInstall::WindowsInstaller) + } + #[cfg(target_os = "linux")] + { + if let Some(val) = appimage_env { + let path = PathBuf::from(val); + if path.is_file() { + return Ok(DesktopInstall::AppImage(path)); + } + } + if current_exe.is_file() + && current_exe + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("appimage")) + .unwrap_or(false) + { + return Ok(DesktopInstall::AppImage(current_exe.to_path_buf())); + } + Ok(DesktopInstall::Deb) + } + #[cfg(not(any(windows, target_os = "linux")))] + { + let _ = (appimage_env, current_exe); + Err(Error::msg(format!( + "automatic self-update is not supported on this platform; please download manually from {RELEASES_PAGE_URL}" + ))) + } +} + +/// Detects the desktop installation type using the live environment. +pub fn detect_desktop_install() -> Result { + let exe = std::env::current_exe()?; + detect_desktop_install_from(std::env::var_os("APPIMAGE").as_deref(), &exe) +} + +/// Returns the expected release asset suffix for a desktop installation and CPU architecture. +pub fn gui_asset_suffix_for(install: &DesktopInstall, arch: &str) -> Result<&'static str> { + match install { + DesktopInstall::AppImage(_) => match arch { + "x86_64" => Ok("_amd64.AppImage"), + "aarch64" => Ok("_aarch64.AppImage"), + other => Err(Error::msg(format!( + "unsupported architecture for AppImage: {other}" + ))), + }, + DesktopInstall::Deb => match arch { + "x86_64" => Ok("_amd64.deb"), + "aarch64" => Ok("_arm64.deb"), + other => Err(Error::msg(format!( + "unsupported architecture for deb package: {other}" + ))), + }, + DesktopInstall::WindowsInstaller => match arch { + "x86_64" => Ok("_x64-setup.exe"), + other => Err(Error::msg(format!( + "unsupported architecture for Windows installer: {other}" + ))), + }, + } +} + +/// Returns the expected release asset suffix for the current architecture. +pub fn gui_asset_suffix(install: &DesktopInstall) -> Result<&'static str> { + gui_asset_suffix_for(install, std::env::consts::ARCH) +} + +/// Downloads, verifies, and installs the latest desktop GUI release in place. +pub fn apply_desktop(current_exe: &Path) -> Result { + apply_desktop_with_progress(current_exe, |_| {}) +} + +/// Downloads, verifies, and installs the latest desktop GUI release in place, +/// notifying the progress callback of phase transitions (`"downloading"`, +/// `"verifying"`, `"installing"`, `"restarting"`). +pub fn apply_desktop_with_progress(current_exe: &Path, on_progress: F) -> Result +where + F: Fn(&str), +{ + let install = + detect_desktop_install_from(std::env::var_os("APPIMAGE").as_deref(), current_exe)?; + let body = curl_text(LATEST_RELEASE_URL)?; + let release: Release = serde_json::from_str(&body)?; + + let suffix = gui_asset_suffix(&install)?; + let asset = release + .assets + .iter() + .find(|a| a.name.ends_with(suffix)) + .ok_or_else(|| { + Error::msg(format!( + "no asset ending with `{suffix}` in release {}", + release.tag_name + )) + })?; + let checksum_asset = release + .assets + .iter() + .find(|a| a.name == format!("{}.sha256", asset.name)); + + let tmp_dir = std::env::temp_dir().join("guara-desktop-update"); + fs::create_dir_all(&tmp_dir)?; + let download_path = tmp_dir.join(&asset.name); + + on_progress("downloading"); + curl_download(&asset.browser_download_url, &download_path)?; + + if let Some(checksum_asset) = checksum_asset { + on_progress("verifying"); + let expected = curl_text(&checksum_asset.browser_download_url)?; + let expected_hash = expected + .split_whitespace() + .next() + .unwrap_or("") + .to_lowercase(); + let data = fs::read(&download_path)?; + let mut hasher = Sha256::new(); + hasher.update(&data); + let actual_hash: String = hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + if actual_hash != expected_hash { + return Err(Error::msg( + "checksum SHA-256 não confere — atualização descartada", + )); + } + } + + on_progress("installing"); + match install { + DesktopInstall::AppImage(target_appimage) => { + #[cfg(target_os = "linux")] + { + let parent_dir = target_appimage.parent().unwrap_or_else(|| Path::new(".")); + let temp_name = format!( + ".{}.tmp-{}", + target_appimage + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("appimage"), + std::process::id() + ); + let temp_file = parent_dir.join(temp_name); + fs::copy(&download_path, &temp_file)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&temp_file)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&temp_file, perms)?; + } + fs::rename(&temp_file, &target_appimage)?; + let _ = fs::remove_file(&download_path); + + on_progress("restarting"); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let err = Command::new(&target_appimage) + .args(std::env::args().skip(1)) + .exec(); + return Err(Error::msg(format!("failed to relaunch AppImage: {err}"))); + } + } + #[cfg(not(target_os = "linux"))] + { + let _ = (&target_appimage, &download_path); + return Err(Error::msg("AppImage updates are only supported on Linux")); + } + } + DesktopInstall::Deb => { + #[cfg(target_os = "linux")] + { + let status = Command::new("dpkg") + .args(["-i"]) + .arg(&download_path) + .status() + .map_err(|e| Error::msg(format!("dpkg -i: {e}")))?; + if !status.success() { + return Err(Error::msg(format!( + "dpkg -i failed with exit code {:?}", + status.code() + ))); + } + let _ = fs::remove_file(&download_path); + + on_progress("restarting"); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let err = Command::new(current_exe) + .args(std::env::args().skip(1)) + .exec(); + return Err(Error::msg(format!("failed to relaunch updated app: {err}"))); + } + } + #[cfg(not(target_os = "linux"))] + { + let _ = (&download_path, current_exe); + return Err(Error::msg("deb updates are only supported on Linux")); + } + } + DesktopInstall::WindowsInstaller => { + #[cfg(windows)] + { + // `/S` is the one NSIS silent-install switch with a documented, + // stable meaning. There is no standard NSIS or Tauri flag that + // makes a silent install relaunch the app afterwards — that was + // claimed in an earlier draft of this code and could not be + // verified against NSIS's or Tauri's documentation; passing an + // invented flag to a stock NSIS installer is silently ignored, + // not harmful, but also not a relaunch mechanism. Spawning + // detached and exiting immediately (instead of waiting on the + // child) also avoids a self-deadlock: if the installer's script + // waits for this process to close before it can overwrite the + // running .exe, blocking here on the installer's exit status + // would hang forever. The practical effect: the update installs + // silently, but the app does not reopen itself — the user needs + // to start it again. This needs real-world confirmation on + // Windows before relying on it; if testing shows the bundled + // NSIS template does support an explicit relaunch flag or step, + // wire it in here instead of this comment. + on_progress("restarting"); + Command::new(&download_path) + .arg("/S") + .spawn() + .map_err(|e| Error::msg(format!("failed to launch installer: {e}")))?; + std::process::exit(0); + } + #[cfg(not(windows))] + { + let _ = &download_path; + return Err(Error::msg( + "Windows installer updates are only supported on Windows", + )); + } + } + } + + #[allow(unreachable_code)] + Ok(release.tag_name) +} + #[cfg(test)] mod tests { use super::*; @@ -234,4 +501,70 @@ mod tests { let err = apply(&PathBuf::from("C:/Program Files/Guara/guara-gui.exe")).unwrap_err(); assert!(err.to_string().contains(RELEASES_PAGE_URL), "{err}"); } + + #[test] + fn detect_desktop_install_variants() { + #[cfg(target_os = "linux")] + { + let temp_appimage = + std::env::temp_dir().join(format!("test-{}.AppImage", std::process::id())); + let _ = fs::write(&temp_appimage, b"dummy appimage"); + + let res = detect_desktop_install_from( + Some(temp_appimage.as_os_str()), + Path::new("/usr/bin/guara-gui"), + ) + .unwrap(); + assert_eq!(res, DesktopInstall::AppImage(temp_appimage.clone())); + + let res_fallback = detect_desktop_install_from(None, &temp_appimage).unwrap(); + assert_eq!( + res_fallback, + DesktopInstall::AppImage(temp_appimage.clone()) + ); + + let _ = fs::remove_file(&temp_appimage); + + let res_deb = + detect_desktop_install_from(None, Path::new("/usr/bin/guara-gui")).unwrap(); + assert_eq!(res_deb, DesktopInstall::Deb); + } + + #[cfg(windows)] + { + let res = detect_desktop_install_from( + None, + Path::new("C:/Program Files/Guara/guara-gui.exe"), + ) + .unwrap(); + assert_eq!(res, DesktopInstall::WindowsInstaller); + } + } + + #[test] + fn gui_asset_suffix_matches_known_release_naming() { + let appimage = DesktopInstall::AppImage(PathBuf::from("/tmp/Guara.AppImage")); + assert_eq!( + gui_asset_suffix_for(&appimage, "x86_64").unwrap(), + "_amd64.AppImage" + ); + assert_eq!( + gui_asset_suffix_for(&appimage, "aarch64").unwrap(), + "_aarch64.AppImage" + ); + + let deb = DesktopInstall::Deb; + assert_eq!(gui_asset_suffix_for(&deb, "x86_64").unwrap(), "_amd64.deb"); + assert_eq!(gui_asset_suffix_for(&deb, "aarch64").unwrap(), "_arm64.deb"); + + let win = DesktopInstall::WindowsInstaller; + assert_eq!( + gui_asset_suffix_for(&win, "x86_64").unwrap(), + "_x64-setup.exe" + ); + + assert!(gui_asset_suffix_for(&appimage, "mips").is_err()); + assert!(gui_asset_suffix_for(&deb, "riscv64").is_err()); + assert!(gui_asset_suffix_for(&win, "arm").is_err()); + } } diff --git a/scripts/build-gui.sh b/scripts/build-gui.sh index 52bf506..d98139a 100755 --- a/scripts/build-gui.sh +++ b/scripts/build-gui.sh @@ -167,4 +167,17 @@ EOF *) echo "unknown platform: $platform" >&2; exit 2 ;; esac +( + cd ../../../dist-gui + for asset in *; do + [ -f "$asset" ] || continue + case "$asset" in + *.sha256) ;; + *) + sha256sum "$asset" > "$asset.sha256" + ;; + esac + done +) + ls -1 ../../../dist-gui