5 Commits
Author SHA1 Message Date
Lucas RayanandClaude Sonnet 5 43e9860b41 chore(release): bump version to 1.3.5, move repository to the ciberfauna org
CI / Format and clippy (push) Successful in 19s
CI / Dependency audit (push) Successful in 9s
Release / Create release (push) Successful in 8s
CI / Check desktop app (Linux) (push) Successful in 1m24s
Release / CLI (linux-x86_64) (push) Successful in 2m13s
CI / Tests (Linux) (push) Successful in 4m10s
Release / CLI (linux-aarch64) (push) Successful in 1m48s
Release / Desktop app (linux-x86_64) (push) Successful in 5m45s
Release / Desktop app (linux-aarch64) (push) Successful in 5m18s
Release / Desktop app (windows) (push) Successful in 4m19s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-31 10:19:25 -03:00
Lucas RayanandClaude Sonnet 5 81f067abdb chore(release): bump version to 1.3.5
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-31 10:15:17 -03:00
Lucas RayanandClaude Sonnet 5 cdda2bc45f feat(update): one-click desktop self-update (AppImage, .deb, Windows)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-31 10:13:56 -03:00
Lucas RayanandClaude Sonnet 5 21eb4eafae chore(release): bump version to 1.3.4
Release / Create release (push) Successful in 6s
CI / Check desktop app (Linux) (push) Successful in 1m22s
Release / CLI (linux-x86_64) (push) Successful in 1m45s
CI / Tests (Linux) (push) Successful in 4m7s
Release / CLI (linux-aarch64) (push) Successful in 2m36s
Release / Desktop app (linux-x86_64) (push) Successful in 4m58s
Release / Desktop app (windows) (push) Successful in 3m44s
CI / Format and clippy (push) Successful in 22s
CI / Dependency audit (push) Successful in 10s
Release / Desktop app (linux-aarch64) (push) Successful in 8m31s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-31 08:08:55 -03:00
Lucas RayanandClaude Sonnet 5 5ecf40056f fix(gui): hide the window until elevation is resolved, not after
The window used to be visible from launch, so the unprivileged
instance's normal window would show, flash while pkexec/osascript
asked for the admin password, and only then get replaced by the
elevated relaunch's window once it killed this one ~1.2s later — the
same mechanism Sabiá already uses under the hood, but with Sabiá's
window visible throughout too; Guará's heavier initial UI made the
unprivileged flash far more noticeable.

tauri.conf.json now starts the window with "visible": false. main.rs
shows it explicitly in exactly one of three cases: already elevated
(or non-unix, where the OS-level UAC manifest already elevated the
process before it started), the relaunch failed and the user needs to
see the warning, or this is the already-elevated relaunched instance
itself. Only one window is ever shown to the user now.

Also fixes a latent bug this surfaced: do_relaunch_elevated only
emitted "elevate-failed" from the child-process-exited-nonzero path.
If it fails earlier (pkexec/osascript missing, exe path unresolved,
log file unwritable) it returns Err directly without emitting
anything — with the window now starting hidden, that would have left
it hidden forever with no feedback. The setup() closure now emits the
same event on that early-Err path too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
2026-10-31 08:07:57 -03:00
13 changed files with 499 additions and 27 deletions

No files matched your search

+13
View File
@@ -4,6 +4,19 @@
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer.
## [1.3.5] - 2026-10-31
### Added
- **One-click desktop update.** The "Update now" button actually downloads, verifies (SHA-256) and installs the new version now, instead of only linking to the Releases page. Works for all three desktop packages: AppImage (atomic self-replace), `.deb` (`dpkg -i`, no new password prompt — the app is already running as root/administrator by the time you click it), and the Windows installer (silent `/S` install). On Linux, the app restarts itself automatically; on Windows, the installer runs silently but the app does not reopen on its own yet — start it again once the install finishes.
### Changed
- **The repository moved to the Ciberfauna organization** (`ciberfauna/guara`, was `lucasrguerra/guara`). **Important notice for existing installations:** versions up to 1.3.4 have the old location baked into their update check; they will not find this release automatically. Download 1.3.5 once manually from the new Releases page, and future update checks will work through the new location.
## [1.3.4] - 2026-10-31
### Fixed
- **Desktop app: only one window on startup.** The main window used to be visible from launch, so the unprivileged instance's normal window would show, flash while the system asked for the administrator password, and only then get replaced by the elevated relaunch's window a moment later. The window now stays hidden until elevation is resolved — either already elevated, elevation failed (the window then shows the warning), or this is the already-elevated relaunched instance — so only one window ever appears, matching Sabiá.
## [1.3.3] - 2026-10-30
Unified updater architecture with Sabiá: migrated from the signed `tauri-plugin-updater` to direct Gitea Releases API checking and self-updating with SHA-256 verification.
+13
View File
@@ -4,6 +4,19 @@
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer.
## [1.3.5] - 2026-10-31
### Adicionado
- **Atualização do app desktop em um clique.** O botão "Atualizar agora" agora baixa, confere (SHA-256) e instala a versão nova de verdade, em vez de só linkar pra página de Releases. Funciona nos três pacotes do desktop: AppImage (substituição atômica), `.deb` (`dpkg -i`, sem pedir senha de novo — o app já roda como root/administrador quando você clica), e o instalador do Windows (instalação silenciosa `/S`). No Linux o app reabre sozinho; no Windows o instalador roda silencioso mas o app ainda não reabre por conta própria — abra de novo depois que a instalação terminar.
### Alterado
- **O repositório se mudou pra organização Ciberfauna** (`ciberfauna/guara`, era `lucasrguerra/guara`). **Aviso importante pra instalações existentes:** versões até a 1.3.4 têm o endereço antigo fixo na checagem de atualização e não vão encontrar esta versão sozinhas. Baixe a 1.3.5 manualmente uma vez na nova página de Releases, e as próximas verificações voltam a funcionar pelo novo endereço.
## [1.3.4] - 2026-10-31
### Corrigido
- **App desktop: só uma janela na abertura.** A janela principal ficava visível desde o lançamento, então a instância sem privilégio mostrava a janela normal, piscava enquanto o sistema pedia a senha de administrador, e só depois era substituída pela janela da instância já elevada. Agora a janela fica escondida até a elevação ser resolvida — já elevada, falhou (a janela aí mostra o aviso), ou é a própria instância já relançada como administrador — então só uma janela aparece, igual ao Sabiá.
## [1.3.3] - 2026-10-30
Arquitetura de atualizador unificada com o Sabiá: migração do `tauri-plugin-updater` assinado para consulta direta à API de Releases do Gitea e autoatualização com verificação de SHA-256.
Generated
+2 -2
View File
@@ -416,7 +416,7 @@ dependencies = [
[[package]]
name = "guara-cli"
version = "1.3.3"
version = "1.3.5"
dependencies = [
"clap",
"ctrlc",
@@ -429,7 +429,7 @@ dependencies = [
[[package]]
name = "guara-core"
version = "1.3.3"
version = "1.3.5"
dependencies = [
"aes",
"argon2",
+2 -2
View File
@@ -3,10 +3,10 @@ resolver = "2"
members = ["crates/guara-core", "crates/guara-cli"]
[workspace.package]
version = "1.3.3"
version = "1.3.5"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/guara"
repository = "https://git.lucasrguerra.dev.br/ciberfauna/guara"
authors = ["Guará contributors"]
rust-version = "1.80"
+2 -2
View File
@@ -1342,7 +1342,7 @@ dependencies = [
[[package]]
name = "guara-core"
version = "1.3.3"
version = "1.3.5"
dependencies = [
"aes",
"argon2",
@@ -1373,7 +1373,7 @@ dependencies = [
[[package]]
name = "guara-gui"
version = "1.3.3"
version = "1.3.5"
dependencies = [
"guara-core",
"libc",
+2 -2
View File
@@ -3,11 +3,11 @@
[package]
name = "guara-gui"
description = "Guará: graphical file recovery interface"
version = "1.3.3"
version = "1.3.5"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["Guará contributors"]
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/guara"
repository = "https://git.lucasrguerra.dev.br/ciberfauna/guara"
rust-version = "1.80"
[workspace]
+49 -10
View File
@@ -28,8 +28,19 @@ fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, Str
}
#[tauri::command]
fn update_apply(state: tauri::State<'_, State>) -> Result<String, String> {
state.0.update_apply().map_err(|e| e.to_string())
fn update_apply(app: tauri::AppHandle, state: tauri::State<'_, State>) -> Result<String, String> {
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.
@@ -247,6 +258,7 @@ fn main() {
std::thread::spawn(move || responder.respond(serve_file(&eng, &request)));
})
.setup(|app| {
use tauri::{Listener, Manager};
// When launched elevated by the unprivileged instance, terminate the old process.
#[cfg(unix)]
if let Some(pid) = std::env::var("GUARA_REPLACE_PID")
@@ -264,15 +276,42 @@ fn main() {
});
}
// Reading raw disks requires root: request elevation as soon as the app launches,
// without waiting for the user to look for a button. If authorization fails,
// the window stays open and the UI displays a warning.
// The window starts hidden (tauri.conf.json: "visible": false) so the
// unprivileged instance never flashes on screen before being replaced.
// Reading raw disks requires root: request elevation as soon as the app
// launches, without waiting for the user to look for a button, and only
// show a window once we know which one it is — the elevated relaunch's,
// or this same one if elevation turned out not to be needed/possible.
#[cfg(any(target_os = "linux", target_os = "macos"))]
if unsafe { libc::geteuid() != 0 } && std::env::var("GUARA_REPLACE_PID").is_err() {
let handle = app.handle().clone();
std::thread::spawn(move || {
let _ = do_relaunch_elevated(handle);
});
{
if unsafe { libc::geteuid() != 0 } && std::env::var("GUARA_REPLACE_PID").is_err() {
use tauri::Emitter;
let handle = app.handle().clone();
let handle_for_failure = app.handle().clone();
std::thread::spawn(move || {
// do_relaunch_elevated only emits "elevate-failed" itself once the
// child process has actually spawned and later exits non-zero. If it
// fails earlier (pkexec/osascript missing, exe path unresolved, log
// file unwritable), it returns Err directly without emitting anything
// — emit it here too, or the hidden window would never reappear.
if let Err(e) = do_relaunch_elevated(handle) {
let _ = handle_for_failure.emit("elevate-failed", e);
}
});
// Whichever path emits it, make sure the window comes back so the
// user sees the warning instead of a silently stuck, invisible app.
if let Some(win) = app.get_webview_window("main") {
app.handle().listen("elevate-failed", move |_| {
let _ = win.show();
});
}
} else if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
}
Ok(())
})
+3 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Guará",
"version": "1.3.3",
"version": "1.3.5",
"identifier": "dev.guara.app",
"build": {
"frontendDist": "../ui"
@@ -15,7 +15,8 @@
"height": 780,
"minWidth": 420,
"minHeight": 560,
"resizable": true
"resizable": true,
"visible": false
}
],
"security": {
+46 -2
View File
@@ -750,7 +750,11 @@ $('#settingsBtn')?.addEventListener('click', () => {
${tauri?.core?.invoke ? `<label class="check"><input type="checkbox" name="updates" ${SETTINGS.updates ? 'checked' : ''}><span><b>Atualizações automáticas</b><br><span class="sub">Ao abrir, consulta o Gitea para ver se há versão nova (uma consulta, sem enviar dados seus)</span><br><button type="button" class="btn sm" id="updNow">Verificar agora</button></span></label>` : ''}
<menu><button class="btn" value="cancel">Cancelar</button><button class="btn primary" value="ok">Salvar</button></menu></form>`;
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 = `<div class="upd-head"><b>Nova versão do Guará disponível</b><span class="mono upd-ver">v${esc(UPD.current)} → v${esc(UPD.version)}</span></div>
<p class="sub">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:</p><p class="mono upd-url">${esc(UPD.url)}</p>
<p class="sub">Instalação automática da nova versão com um clique:</p>
<div class="upd-actions">
<button class="btn primary sm" id="updApply">Atualizar agora</button>
<button class="btn sm" id="updLater">Depois</button>
</div>`;
$('#updLater')?.addEventListener('click', () => { UPD = null; el.remove(); });
$('#updApply')?.addEventListener('click', () => applyUpdate());
}
// ------------------------------------------------------------------ close while scan is running
+6 -1
View File
@@ -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}',
};
+11
View File
@@ -1323,6 +1323,17 @@ impl Engine {
let exe = std::env::current_exe()?;
crate::update::apply(&exe)
}
pub fn update_apply_desktop<F>(
&self,
current_exe: &std::path::Path,
on_progress: F,
) -> Result<String>
where
F: Fn(&str),
{
crate::update::apply_desktop_with_progress(current_exe, on_progress)
}
}
pub use crate::explorer::Query;
+337 -4
View File
@@ -9,15 +9,15 @@ 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`).
const LATEST_RELEASE_URL: &str =
"https://git.lucasrguerra.dev.br/api/v1/repos/lucasrguerra/guara/releases/latest";
"https://git.lucasrguerra.dev.br/api/v1/repos/ciberfauna/guara/releases/latest";
/// Human-facing Gitea Releases page for desktop GUI users.
const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/lucasrguerra/guara/releases";
const RELEASES_PAGE_URL: &str = "https://git.lucasrguerra.dev.br/ciberfauna/guara/releases";
#[derive(Debug, Deserialize)]
struct Release {
@@ -52,7 +52,7 @@ fn curl_text(url: &str) -> Result<String> {
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<PathBuf> {
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<DesktopInstall> {
#[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<DesktopInstall> {
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<String> {
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<F>(current_exe: &Path, on_progress: F) -> Result<String>
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());
}
}
+13
View File
@@ -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