3 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
13 changed files with 451 additions and 18 deletions

No files matched your search

+8
View File
@@ -4,6 +4,14 @@
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer. 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 ## [1.3.4] - 2026-10-31
### Fixed ### Fixed
+8
View File
@@ -4,6 +4,14 @@
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem SemVer. 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 ## [1.3.4] - 2026-10-31
### Corrigido ### Corrigido
Generated
+2 -2
View File
@@ -416,7 +416,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-cli" name = "guara-cli"
version = "1.3.4" version = "1.3.5"
dependencies = [ dependencies = [
"clap", "clap",
"ctrlc", "ctrlc",
@@ -429,7 +429,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-core" name = "guara-core"
version = "1.3.4" version = "1.3.5"
dependencies = [ dependencies = [
"aes", "aes",
"argon2", "argon2",
+2 -2
View File
@@ -3,10 +3,10 @@ resolver = "2"
members = ["crates/guara-core", "crates/guara-cli"] members = ["crates/guara-core", "crates/guara-cli"]
[workspace.package] [workspace.package]
version = "1.3.4" version = "1.3.5"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" 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"] authors = ["Guará contributors"]
rust-version = "1.80" rust-version = "1.80"
+2 -2
View File
@@ -1342,7 +1342,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-core" name = "guara-core"
version = "1.3.4" version = "1.3.5"
dependencies = [ dependencies = [
"aes", "aes",
"argon2", "argon2",
@@ -1373,7 +1373,7 @@ dependencies = [
[[package]] [[package]]
name = "guara-gui" name = "guara-gui"
version = "1.3.4" version = "1.3.5"
dependencies = [ dependencies = [
"guara-core", "guara-core",
"libc", "libc",
+2 -2
View File
@@ -3,11 +3,11 @@
[package] [package]
name = "guara-gui" name = "guara-gui"
description = "Guará: graphical file recovery interface" description = "Guará: graphical file recovery interface"
version = "1.3.4" version = "1.3.5"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
authors = ["Guará contributors"] authors = ["Guará contributors"]
repository = "https://git.lucasrguerra.dev.br/lucasrguerra/guara" repository = "https://git.lucasrguerra.dev.br/ciberfauna/guara"
rust-version = "1.80" rust-version = "1.80"
[workspace] [workspace]
+13 -2
View File
@@ -28,8 +28,19 @@ fn update_check(state: tauri::State<'_, State>) -> Result<serde_json::Value, Str
} }
#[tauri::command] #[tauri::command]
fn update_apply(state: tauri::State<'_, State>) -> Result<String, String> { fn update_apply(app: tauri::AppHandle, state: tauri::State<'_, State>) -> Result<String, String> {
state.0.update_apply().map_err(|e| e.to_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. /// `true` when the process already has sufficient privileges to read raw disks.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Guará", "productName": "Guará",
"version": "1.3.4", "version": "1.3.5",
"identifier": "dev.guara.app", "identifier": "dev.guara.app",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"
+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>` : ''} ${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>`; <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('[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 = () => { d.onclose = () => {
if (d.returnValue !== 'ok') return; 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 }; 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. // The desktop shell (Tauri) queries Gitea Releases API and notifies the user.
// No-op in browser mode (`guara ui`). // No-op in browser mode (`guara ui`).
let UPD = null; 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) { async function checkUpdate(manual) {
if (!tauri?.core?.invoke) return; if (!tauri?.core?.invoke) return;
@@ -801,11 +843,13 @@ function renderUpdateCard() {
if (!UPD) { el?.remove(); return; } 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); } 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> 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"> <div class="upd-actions">
<button class="btn primary sm" id="updApply">Atualizar agora</button>
<button class="btn sm" id="updLater">Depois</button> <button class="btn sm" id="updLater">Depois</button>
</div>`; </div>`;
$('#updLater')?.addEventListener('click', () => { UPD = null; el.remove(); }); $('#updLater')?.addEventListener('click', () => { UPD = null; el.remove(); });
$('#updApply')?.addEventListener('click', () => applyUpdate());
} }
// ------------------------------------------------------------------ close while scan is running // ------------------------------------------------------------------ close while scan is running
+6 -1
View File
@@ -582,7 +582,7 @@ export const EN = {
'Atualizando o Guará…': 'Updating Guará…', 'Atualizando o Guará…': 'Updating Guará…',
'Baixando a atualização…': 'Downloading the update…', 'Baixando a atualização…': 'Downloading the update…',
'Baixando… {n}%': 'Downloading… {n}%', '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:', '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', '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)', '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', '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', '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}', '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()?; let exe = std::env::current_exe()?;
crate::update::apply(&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; pub use crate::explorer::Query;
+337 -4
View File
@@ -9,15 +9,15 @@ use crate::error::{Error, Result};
use serde::Deserialize; use serde::Deserialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
/// Gitea `releases/latest` API endpoint (`tag_name`, `assets[].name`, `assets[].browser_download_url`). /// Gitea `releases/latest` API endpoint (`tag_name`, `assets[].name`, `assets[].browser_download_url`).
const LATEST_RELEASE_URL: &str = 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. /// 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)] #[derive(Debug, Deserialize)]
struct Release { struct Release {
@@ -52,7 +52,7 @@ fn curl_text(url: &str) -> Result<String> {
Ok(String::from_utf8_lossy(&out.stdout).into_owned()) 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") let status = Command::new("curl")
.args(["-fsSL", "-H", "User-Agent: guara-updater", "-o"]) .args(["-fsSL", "-H", "User-Agent: guara-updater", "-o"])
.arg(dest) .arg(dest)
@@ -223,6 +223,273 @@ fn find_binary(dir: &PathBuf) -> Result<PathBuf> {
Err(Error::msg("binário não encontrado no pacote baixado")) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -234,4 +501,70 @@ mod tests {
let err = apply(&PathBuf::from("C:/Program Files/Guara/guara-gui.exe")).unwrap_err(); let err = apply(&PathBuf::from("C:/Program Files/Guara/guara-gui.exe")).unwrap_err();
assert!(err.to_string().contains(RELEASES_PAGE_URL), "{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 ;; *) echo "unknown platform: $platform" >&2; exit 2 ;;
esac 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 ls -1 ../../../dist-gui