Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43e9860b41 | ||
|
|
81f067abdb | ||
|
|
cdda2bc45f | ||
|
|
21eb4eafae | ||
|
|
5ecf40056f |
No files matched your search
@@ -4,6 +4,19 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
### 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
|
## [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.
|
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.
|
||||||
|
|||||||
@@ -4,6 +4,19 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
### 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
|
## [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.
|
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
@@ -416,7 +416,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-cli"
|
name = "guara-cli"
|
||||||
version = "1.3.3"
|
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.3"
|
version = "1.3.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
+2
-2
@@ -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.3"
|
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"
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1342,7 +1342,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "guara-core"
|
name = "guara-core"
|
||||||
version = "1.3.3"
|
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.3"
|
version = "1.3.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"guara-core",
|
"guara-core",
|
||||||
"libc",
|
"libc",
|
||||||
|
|||||||
@@ -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.3"
|
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]
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -247,6 +258,7 @@ fn main() {
|
|||||||
std::thread::spawn(move || responder.respond(serve_file(&eng, &request)));
|
std::thread::spawn(move || responder.respond(serve_file(&eng, &request)));
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
use tauri::{Listener, Manager};
|
||||||
// When launched elevated by the unprivileged instance, terminate the old process.
|
// When launched elevated by the unprivileged instance, terminate the old process.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
if let Some(pid) = std::env::var("GUARA_REPLACE_PID")
|
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,
|
// The window starts hidden (tauri.conf.json: "visible": false) so the
|
||||||
// without waiting for the user to look for a button. If authorization fails,
|
// unprivileged instance never flashes on screen before being replaced.
|
||||||
// the window stays open and the UI displays a warning.
|
// 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"))]
|
#[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();
|
if unsafe { libc::geteuid() != 0 } && std::env::var("GUARA_REPLACE_PID").is_err() {
|
||||||
std::thread::spawn(move || {
|
use tauri::Emitter;
|
||||||
let _ = do_relaunch_elevated(handle);
|
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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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.3",
|
"version": "1.3.5",
|
||||||
"identifier": "dev.guara.app",
|
"identifier": "dev.guara.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
@@ -15,7 +15,8 @@
|
|||||||
"height": 780,
|
"height": 780,
|
||||||
"minWidth": 420,
|
"minWidth": 420,
|
||||||
"minHeight": 560,
|
"minHeight": 560,
|
||||||
"resizable": true
|
"resizable": true,
|
||||||
|
"visible": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
+46
-2
@@ -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
|
||||||
|
|||||||
@@ -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}',
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user