From 6191c7bbc64ca1224801eb07ca71221d993f7881 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sat, 31 Oct 2026 10:18:31 -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 Same change as Guará: update_apply used to flatly refuse to run for the GUI binary and point the user to a manual download instead. Since the desktop app already elevates to root before showing its window, the process applying the update already has everything it needs — no second authentication round is required. - New sabia_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, 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), verifies SHA-256 when a sidecar is published, and installs: - AppImage: downloads to a temp file in the same directory, renames over the running file, execs it in place (same PID, already root). - .deb: runs dpkg -i directly (already root), execs current_exe() again now that dpkg replaced it on disk. - Windows: runs the installer silently and detached, exits immediately to release the file lock. Does not claim to relaunch the app afterwards — no documented NSIS or Tauri flag does that; needs a real Windows machine to confirm actual post-install behavior. - Engine::update_apply_desktop wires it into the Tauri update_apply command (now takes AppHandle, emits update-progress events). - Fixed a real gap: the existing update button handler changed its own label when an update was available but never actually called apply — wired it for real, with per-phase progress feedback in the interface's existing language (download/verify/install/restart). - scripts/build-gui.sh: generates a checksum file next to every file in dist-gui/, matching the CLI release archive convention. - 3 new unit tests: install-kind detection, GUI asset suffix matching against real release naming, existing CLI-refusal test 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 | 52 ++++- apps/gui/ui/i18n-en.js | 6 + crates/sabia-core/src/engine.rs | 11 + crates/sabia-core/src/update.rs | 368 +++++++++++++++++++++++++++++++- scripts/build-gui.sh | 17 +- 6 files changed, 455 insertions(+), 14 deletions(-) diff --git a/apps/gui/src-tauri/src/main.rs b/apps/gui/src-tauri/src/main.rs index da352b4..3134c49 100644 --- a/apps/gui/src-tauri/src/main.rs +++ b/apps/gui/src-tauri/src/main.rs @@ -502,8 +502,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 for all diff --git a/apps/gui/ui/app.js b/apps/gui/ui/app.js index 5c98c4c..9c44375 100644 --- a/apps/gui/ui/app.js +++ b/apps/gui/ui/app.js @@ -267,6 +267,8 @@ async function tauriApi(path, opts = {}) { return await invoke('export_all_hc22000', { iface: q.get('iface'), out: q.get('out') }); case 'GET /api/update/check': return await invoke('update_check'); + case 'POST /api/update/apply': + return await invoke('update_apply'); default: return { error: 'rota desconhecida' }; } @@ -371,12 +373,54 @@ els.btnScan.addEventListener('click', async () => { } }); +let updateStatus = null; +let updateInProgress = false; + els.btnUpdate.addEventListener('click', async () => { + if (updateInProgress) return; + if (updateStatus?.update_available) { + updateInProgress = true; + els.btnUpdate.disabled = true; + els.btnUpdate.textContent = 'baixando…'; + + let unlisten = null; + if (tauri?.event?.listen) { + try { + unlisten = await tauri.event.listen('update-progress', (event) => { + const phase = event.payload?.phase; + if (phase === 'downloading') els.btnUpdate.textContent = 'baixando…'; + else if (phase === 'verifying') els.btnUpdate.textContent = 'verificando…'; + else if (phase === 'installing') els.btnUpdate.textContent = 'instalando…'; + else if (phase === 'restarting') els.btnUpdate.textContent = 'reiniciando…'; + }); + } catch (_) {} + } + + try { + await api('/api/update/apply', { method: 'POST' }); + els.btnUpdate.textContent = 'já atualizado'; + } catch (err) { + updateInProgress = false; + els.btnUpdate.disabled = false; + els.btnUpdate.textContent = `atualizar para ${updateStatus.latest_version}`; + alert('Falha na atualização: ' + (err?.message || err)); + } finally { + if (unlisten) unlisten(); + } + return; + } + els.btnUpdate.textContent = 'checando…'; - const status = await api('/api/update/check'); - els.btnUpdate.textContent = status.update_available - ? `atualizar para ${status.latest_version}` - : 'já atualizado'; + try { + const status = await api('/api/update/check'); + updateStatus = status; + els.btnUpdate.textContent = status?.update_available + ? `atualizar para ${status.latest_version}` + : 'já atualizado'; + } catch (err) { + els.btnUpdate.textContent = 'checar atualização'; + alert('Não foi possível verificar atualizações: ' + (err?.message || err)); + } }); const SEC_RANK = { diff --git a/apps/gui/ui/i18n-en.js b/apps/gui/ui/i18n-en.js index a0f0777..57d4dbd 100644 --- a/apps/gui/ui/i18n-en.js +++ b/apps/gui/ui/i18n-en.js @@ -12,6 +12,12 @@ export const EN = { 'checando…': 'checking…', 'atualizar para {n}': 'update to {n}', 'já atualizado': 'already up to date', + 'baixando…': 'downloading…', + 'verificando…': 'verifying…', + 'instalando…': 'installing…', + 'reiniciando…': 'restarting…', + 'Falha na atualização:': 'Update failed:', + 'Não foi possível verificar atualizações:': 'Could not check for updates:', // topbar 'Painel de captura': 'Capture dashboard', diff --git a/crates/sabia-core/src/engine.rs b/crates/sabia-core/src/engine.rs index 700eb84..c94a1d7 100644 --- a/crates/sabia-core/src/engine.rs +++ b/crates/sabia-core/src/engine.rs @@ -2979,6 +2979,17 @@ impl Engine { update::apply(&exe) } + pub fn update_apply_desktop( + &self, + current_exe: &std::path::Path, + on_progress: F, + ) -> Result + where + F: Fn(&str), + { + update::apply_desktop_with_progress(current_exe, on_progress) + } + fn session_handshake( &self, name: &str, diff --git a/crates/sabia-core/src/update.rs b/crates/sabia-core/src/update.rs index 028fb87..7e96b0e 100644 --- a/crates/sabia-core/src/update.rs +++ b/crates/sabia-core/src/update.rs @@ -9,16 +9,16 @@ use crate::error::{Result, SabiaError}; 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`). /// const LATEST_RELEASE_URL: &str = - "https://git.lucasrguerra.dev.br/api/v1/repos/lucasrguerra/sabia/releases/latest"; + "https://git.lucasrguerra.dev.br/api/v1/repos/ciberfauna/sabia/releases/latest"; /// Human-facing Gitea Releases page for desktop GUI users. -const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/lucasrguerra/sabia/releases"; +const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/ciberfauna/sabia/releases"; #[derive(Debug, Deserialize)] struct Release { @@ -56,7 +56,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: sabia-updater", "-o"]) .arg(dest) @@ -246,6 +246,300 @@ fn find_binary(dir: &PathBuf) -> Result { }) } +/// 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(SabiaError::CommandFailed { + cmd: "update".into(), + detail: 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(SabiaError::CommandFailed { + cmd: "update".into(), + detail: format!("unsupported architecture for AppImage: {other}"), + }), + }, + DesktopInstall::Deb => match arch { + "x86_64" => Ok("_amd64.deb"), + "aarch64" => Ok("_arm64.deb"), + other => Err(SabiaError::CommandFailed { + cmd: "update".into(), + detail: format!("unsupported architecture for deb package: {other}"), + }), + }, + DesktopInstall::WindowsInstaller => match arch { + "x86_64" => Ok("_x64-setup.exe"), + other => Err(SabiaError::CommandFailed { + cmd: "update".into(), + detail: 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(|| SabiaError::CommandFailed { + cmd: "update".into(), + detail: 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("sabia-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(SabiaError::CommandFailed { + cmd: "update".into(), + detail: "checksum SHA-256 não confere — atualização descartada".into(), + }); + } + } + + 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(SabiaError::CommandFailed { + cmd: "update".into(), + detail: format!("failed to relaunch AppImage: {err}"), + }); + } + } + #[cfg(not(target_os = "linux"))] + { + let _ = (&target_appimage, &download_path); + return Err(SabiaError::CommandFailed { + cmd: "update".into(), + detail: "AppImage updates are only supported on Linux".into(), + }); + } + } + DesktopInstall::Deb => { + #[cfg(target_os = "linux")] + { + let status = Command::new("dpkg") + .args(["-i"]) + .arg(&download_path) + .status() + .map_err(|e| SabiaError::CommandFailed { + cmd: "dpkg -i".into(), + detail: e.to_string(), + })?; + if !status.success() { + return Err(SabiaError::CommandFailed { + cmd: "dpkg -i".into(), + detail: 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(SabiaError::CommandFailed { + cmd: "update".into(), + detail: format!("failed to relaunch updated app: {err}"), + }); + } + } + #[cfg(not(target_os = "linux"))] + { + let _ = (&download_path, current_exe); + return Err(SabiaError::CommandFailed { + cmd: "update".into(), + detail: "deb updates are only supported on Linux".into(), + }); + } + } + 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| SabiaError::CommandFailed { + cmd: "installer".into(), + detail: format!("failed to launch installer: {e}"), + })?; + std::process::exit(0); + } + #[cfg(not(windows))] + { + let _ = &download_path; + return Err(SabiaError::CommandFailed { + cmd: "update".into(), + detail: "Windows installer updates are only supported on Windows".into(), + }); + } + } + } + + #[allow(unreachable_code)] + Ok(release.tag_name) +} + #[cfg(test)] mod tests { use super::*; @@ -258,4 +552,70 @@ mod tests { let err = apply(&PathBuf::from("C:/Program Files/Sabia/sabia-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-sabia-{}.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/sabia-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/sabia-gui")).unwrap(); + assert_eq!(res_deb, DesktopInstall::Deb); + } + + #[cfg(windows)] + { + let res = detect_desktop_install_from( + None, + Path::new("C:/Program Files/Sabia/sabia-gui.exe"), + ) + .unwrap(); + assert_eq!(res, DesktopInstall::WindowsInstaller); + } + } + + #[test] + fn gui_asset_suffix_matches_known_release_naming() { + let appimage = DesktopInstall::AppImage(PathBuf::from("/tmp/Sabia.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 98f74fa..0316446 100755 --- a/scripts/build-gui.sh +++ b/scripts/build-gui.sh @@ -76,8 +76,6 @@ case "$platform" in rm -rf "$bundle/appimage" "$bundle/deb" tauri build --bundles appimage,deb cp "$bundle"/appimage/*.AppImage "$bundle"/deb/*.deb ../../../dist-gui/ - # Include .sig alongside the bundle when updater signing is enabled. - cp "$bundle"/appimage/*.AppImage.sig ../../../dist-gui/ 2>/dev/null || true ;; linux-x86_64) if [ "$(uname -m)" = x86_64 ]; then @@ -165,7 +163,6 @@ EOF tauri build --target x86_64-unknown-linux-gnu --bundles appimage,deb fi cp "$bundle"/appimage/*.AppImage "$bundle"/deb/*.deb ../../../dist-gui/ - cp "$bundle"/appimage/*.AppImage.sig ../../../dist-gui/ 2>/dev/null || true ;; windows) rustup target add x86_64-pc-windows-msvc >/dev/null @@ -173,9 +170,21 @@ EOF rm -rf "$bundle" tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis cp "$bundle"/*-setup.exe ../../../dist-gui/ - cp "$bundle"/*-setup.exe.sig ../../../dist-gui/ 2>/dev/null || true ;; *) 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