diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1531db2..0e14350 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,8 @@ jobs: node-version: 22 - name: Cobertura da tradução da interface run: node scripts/check-ui-i18n.mjs + - name: Script do latest.json do atualizador + run: python3 scripts/test_make_latest_json.py - run: cargo fmt --all -- --check - run: cargo clippy --workspace --all-targets diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50637c1..491816c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,6 +87,8 @@ jobs: name: App desktop (${{ matrix.name }}) needs: create-release runs-on: ${{ matrix.os }} + # Os segredos de assinatura do atualizador ficam no ambiente "release", que só aceita tags v*. + environment: release strategy: fail-fast: false matrix: @@ -118,13 +120,19 @@ jobs: run: scripts/install-tauri-deps.sh - name: Gera os ícones run: npx --yes @tauri-apps/cli@2 icon apps/gui/src-tauri/icons/source.png -o apps/gui/src-tauri/icons + # A chave privada de atualização fica só nos segredos do repositório. Ela assina os pacotes + # que o app baixa sozinho; a chave pública correspondente está em tauri.conf.json. - uses: tauri-apps/tauri-action@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} with: projectPath: apps/gui/src-tauri releaseId: ${{ needs.create-release.outputs.release_id }} args: ${{ matrix.args }} + # o latest.json é montado no job de publicação, de uma vez só (scripts/make-latest-json.py) + uploadUpdaterJson: false publish: name: Publica a release @@ -149,3 +157,11 @@ jobs: body: fs.readFileSync('notes.md', 'utf8'), draft: false, }); + # Depois de publicada (rascunho não é visível por tag), monta e sobe o latest.json. + - name: latest.json do atualizador do app + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + python3 scripts/make-latest-json.py "$GITHUB_REF_NAME" notes.md latest.json + gh release upload "$GITHUB_REF_NAME" latest.json --clobber diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9735a5d..ef602b6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,8 @@ On Linux the tests build images with `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatpro For the external reference images (BitLocker, FileVault 2, dfvfs, AFF4), run `scripts/fetch-testdata.sh`; without them those tests are skipped. The BitLocker test takes about 75 s. +The desktop app signs its update packages: `tauri.conf.json` has `createUpdaterArtifacts` on, so a local `tauri build` needs `TAURI_SIGNING_PRIVATE_KEY` (any key made with `tauri signer generate`; the release key lives only in the repository secrets). For a quick local bundle, pass `--config '{"bundle":{"createUpdaterArtifacts":false}}'`. + ## Where to make changes A map of the modules is in [docs/architecture.md](docs/architecture.md). What is missing and what is welcome is in the [ROADMAP](ROADMAP.md). diff --git a/CONTRIBUTING.pt-BR.md b/CONTRIBUTING.pt-BR.md index 3a85955..86375cd 100644 --- a/CONTRIBUTING.pt-BR.md +++ b/CONTRIBUTING.pt-BR.md @@ -20,6 +20,8 @@ No Linux os testes geram imagens com `mtools`, `ntfs-3g`, `e2fsprogs`, `exfatpro Para as imagens de referência externas (BitLocker, FileVault 2, dfvfs, AFF4), rode `scripts/fetch-testdata.sh`; sem elas esses testes são pulados. O teste de BitLocker leva cerca de 75 s. +O app desktop assina os pacotes de atualização: o `tauri.conf.json` tem `createUpdaterArtifacts` ligado, então um `tauri build` local precisa de `TAURI_SIGNING_PRIVATE_KEY` (qualquer chave feita com `tauri signer generate`; a chave da release fica só nos segredos do repositório). Para um pacote local rápido, passe `--config '{"bundle":{"createUpdaterArtifacts":false}}'`. + ## Onde mexer Mapa dos módulos em [docs/pt-BR/arquitetura.md](docs/pt-BR/arquitetura.md). O que falta e o que é bem-vindo está no [ROADMAP](ROADMAP.pt-BR.md). diff --git a/README.md b/README.md index 0039036..6df36de 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ Download the package for your system from **[Releases](../../releases)**: Every file has a `.sha256` next to it. To build from source: `cargo build --release -p guara-cli` (Rust 1.80+). +**Updates:** the desktop app (AppImage, Windows and macOS) checks GitHub for a newer version when it opens; if there is one, a card offers **Update now**: it downloads, verifies the package signature, installs and reopens, so you no longer swap the AppImage by hand. The check is a single request for the release's `latest.json` and nothing of yours is sent; turn it off in **Settings**. Installs from `.deb`/`.rpm` only show the notice, because the package manager owns those files. + > The binaries are **not signed yet**: Windows SmartScreen and macOS Gatekeeper will warn you. Check the SHA-256. Reading raw disks requires privileges: **administrator** (Windows), `sudo` (Linux and macOS). Even as root, Guará writes sessions, reports and recovered files with the owner and permissions of the user who launched it. diff --git a/README.pt-BR.md b/README.pt-BR.md index d68d82c..db764dc 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -44,6 +44,8 @@ Baixe o pacote do seu sistema em **[Releases](../../releases)**: Cada arquivo tem um `.sha256` ao lado. Compilar do código: `cargo build --release -p guara-cli` (Rust 1.80+). +**Atualizações:** o app desktop (AppImage, Windows e macOS) consulta o GitHub ao abrir; havendo versão nova, um cartão oferece **Atualizar agora**: ele baixa, confere a assinatura do pacote, instala e reabre, e você deixa de trocar o AppImage na mão. A consulta é uma única requisição ao `latest.json` da release e nada seu é enviado; desligue em **Configurações**. Instalações por `.deb`/`.rpm` só mostram o aviso, porque quem manda nesses arquivos é o gerenciador de pacotes. + > Os binários ainda **não são assinados**: o Windows SmartScreen e o Gatekeeper do macOS vão avisar. Confira o SHA-256. Ler discos brutos exige privilégios: **administrador** (Windows), `sudo` (Linux e macOS). Mesmo como root, o Guará grava sessões, relatórios e arquivos recuperados com o dono e as permissões do usuário que o abriu. diff --git a/ROADMAP.md b/ROADMAP.md index 7d9917b..a04a565 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,7 @@ Legend: ✅ done · 🟡 partial · 🔜 planned · 🔭 long-term idea · ⛔ o 1. **The source is read-only.** Nothing in the core writes to the device being recovered. 2. **Do not ship without validating.** A feature only lands with a test against a real or reference image. That is why ReFS and Storage Spaces do not exist yet: there is no reference image to check against. 3. **Honest confidence.** If the content could not be checked, confidence is low or medium, never high. -4. **Free and auditable.** Open source (MIT or Apache-2.0), no telemetry, and every result can be reproduced. +4. **Free and auditable.** Open source (MIT or Apache-2.0), no telemetry (the only thing that touches the network by default is the update check, a single request you can turn off), and every result can be reproduced. ## History diff --git a/ROADMAP.pt-BR.md b/ROADMAP.pt-BR.md index 0352e70..cd209a2 100644 --- a/ROADMAP.pt-BR.md +++ b/ROADMAP.pt-BR.md @@ -11,7 +11,7 @@ Legenda: ✅ feito · 🟡 parcial · 🔜 planejado · 🔭 ideia a longo prazo 1. **A origem é somente leitura.** Nada no núcleo escreve no dispositivo recuperado. 2. **Não entregar sem validar.** Um recurso só entra com teste contra imagem real ou de referência. Por isso ReFS e Storage Spaces ainda não existem: não há imagem de referência para conferir. 3. **Confiança honesta.** Se o conteúdo não pôde ser conferido, a confiança é baixa ou média, nunca alta. -4. **Livre e auditável.** Código aberto (MIT ou Apache-2.0), sem telemetria, e cada resultado pode ser reproduzido. +4. **Livre e auditável.** Código aberto (MIT ou Apache-2.0), sem telemetria (a única coisa que usa a rede por padrão é a verificação de atualização, uma requisição só que você pode desligar), e cada resultado pode ser reproduzido. ## Histórico diff --git a/apps/gui/src-tauri/Cargo.toml b/apps/gui/src-tauri/Cargo.toml index a86729c..f8b939e 100644 --- a/apps/gui/src-tauri/Cargo.toml +++ b/apps/gui/src-tauri/Cargo.toml @@ -17,6 +17,8 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" +tauri-plugin-updater = "2" +serde = { version = "1", features = ["derive"] } serde_json = "1" guara-core = { path = "../../../crates/guara-core" } diff --git a/apps/gui/src-tauri/src/main.rs b/apps/gui/src-tauri/src/main.rs index 9735b66..12b0375 100644 --- a/apps/gui/src-tauri/src/main.rs +++ b/apps/gui/src-tauri/src/main.rs @@ -15,6 +15,73 @@ async fn rpc(state: tauri::State<'_, State>, method: String, params: serde_json: .map_err(|e| e.to_string())? } +/// Versão nova encontrada na release do GitHub. +#[derive(serde::Serialize)] +struct UpdateInfo { + current: String, + version: String, + notes: String, + /// O app consegue se atualizar sozinho (AppImage, Windows e macOS). Instalação por pacote + /// (.deb/.rpm) só avisa e leva à página da release. + auto: bool, + url: String, +} + +fn self_update_possible() -> bool { + !cfg!(target_os = "linux") || std::env::var_os("APPIMAGE").is_some() +} + +/// Procura uma versão mais nova. `None` = já está na última. +#[tauri::command] +async fn update_check(app: tauri::AppHandle) -> Result, String> { + use tauri_plugin_updater::UpdaterExt; + let found = app.updater().map_err(|e| e.to_string())?.check().await.map_err(|e| e.to_string())?; + Ok(found.map(|u| UpdateInfo { + current: u.current_version.clone(), + notes: u.body.clone().unwrap_or_default(), + url: format!("https://github.com/lucasrguerra/guara/releases/tag/v{}", u.version), + version: u.version, + auto: self_update_possible(), + })) +} + +/// Baixa, confere a assinatura (chave pública embutida no app), instala e reabre o app. +/// O progresso sai no evento `update-progress`. +#[tauri::command] +async fn update_install(app: tauri::AppHandle) -> Result<(), String> { + use tauri::Emitter; + use tauri_plugin_updater::UpdaterExt; + if !self_update_possible() { + return Err("Esta instalação não se atualiza sozinha (pacote do sistema). Baixe a versão nova na página da release.".into()); + } + let update = app + .updater() + .map_err(|e| e.to_string())? + .check() + .await + .map_err(|e| e.to_string())? + .ok_or("Já está na versão mais recente.")?; + let (a, b) = (app.clone(), app.clone()); + let mut got = 0u64; + update + .download_and_install( + move |chunk, total| { + got += chunk as u64; + let _ = a.emit("update-progress", serde_json::json!({ "downloaded": got, "total": total })); + }, + move || { + let _ = b.emit("update-progress", serde_json::json!({ "installing": true })); + }, + ) + .await + .map_err(|e| format!("Não foi possível atualizar: {e}"))?; + // Rodando como administrador, o arquivo novo nasce do root: devolve ao usuário que abriu o app. + if let Some(p) = std::env::var_os("APPIMAGE") { + guara_core::owner::give_to_user(std::path::Path::new(&p)); + } + app.restart() +} + /// Reabre o app com privilégios de administrador (Linux/macOS). No Windows o /// manifesto já exige elevação. O app novo encerra este quando abre; se a /// autorização falhar, a janela continua aqui e a interface mostra o motivo. @@ -155,6 +222,7 @@ fn main() { let for_protocol = engine.clone(); tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .manage(State(engine)) .register_asynchronous_uri_scheme_protocol("guara", move |_ctx, request, responder| { let eng = for_protocol.clone(); @@ -176,7 +244,7 @@ fn main() { } Ok(()) }) - .invoke_handler(tauri::generate_handler![rpc, relaunch_elevated]) + .invoke_handler(tauri::generate_handler![rpc, relaunch_elevated, update_check, update_install]) .run(tauri::generate_context!()) .expect("erro ao iniciar o Guará"); } diff --git a/apps/gui/src-tauri/tauri.conf.json b/apps/gui/src-tauri/tauri.conf.json index 004ed1a..f87a7d9 100644 --- a/apps/gui/src-tauri/tauri.conf.json +++ b/apps/gui/src-tauri/tauri.conf.json @@ -34,6 +34,18 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "createUpdaterArtifacts": true + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhENzRCRUU5NEI3QTAxQzgKUldUSUFYcEw2YjUwamFzMnpXM3ZKTEZPN1JIeXdLL3Q5SEF1M1FxQ284WFM0UWNsdlAyNUloWTEK", + "endpoints": [ + "https://github.com/lucasrguerra/guara/releases/latest/download/latest.json" + ], + "windows": { + "installMode": "passive" + } + } } } diff --git a/apps/gui/ui/app.css b/apps/gui/ui/app.css index 3fd19bb..1d0ef07 100644 --- a/apps/gui/ui/app.css +++ b/apps/gui/ui/app.css @@ -518,3 +518,12 @@ dialog.lightbox[open] { display: grid; } /* logo inline: segue o tema escolhido no app, não o do sistema */ .logo .a { stroke: var(--accent); } .logo .b { stroke: var(--accent-2); } .logo .t { fill: var(--text); } + +/* atualização do app */ +.update-card { position: fixed; right: 18px; bottom: 18px; z-index: 60; width: min(400px, calc(100vw - 36px)); display: grid; gap: 10px; padding: 16px 18px; border-radius: 18px; background: var(--surface); box-shadow: var(--shadow); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); } +.update-card .upd-head { display: grid; gap: 2px; } +.update-card .upd-ver { font-size: 12.5px; color: var(--text-2); } +.update-card .upd-url { word-break: break-all; font-size: 12.5px; } +.update-card .upd-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.update-card .sub { margin: 0; } +pre.upd-notes { max-height: 50vh; overflow: auto; white-space: pre-wrap; font-size: 13px; } diff --git a/apps/gui/ui/app.js b/apps/gui/ui/app.js index 4b9d350..aa5bf68 100644 --- a/apps/gui/ui/app.js +++ b/apps/gui/ui/app.js @@ -53,7 +53,7 @@ document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async // ------------------------------------------------------------------ configurações // (declarado antes do uso: `S` é definido mais abaixo, por isso o objeto fica aqui) -const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false }; +const SETTINGS = { theme: 'auto', lang: 'auto', ui_lang: null, notify: true, maps: false, updates: true }; async function saveSettings(patch) { Object.assign(SETTINGS, patch); try { await rpc('settings_set', SETTINGS); } catch { /* segue só com o armazenamento local */ } @@ -745,11 +745,14 @@ $('#settingsBtn')?.addEventListener('click', () => { + ${tauri?.core?.invoke ? `` : ''} `; d.querySelector('[name=lang]').value = SETTINGS.lang; + d.querySelector('#updNow')?.addEventListener('click', (e) => { e.preventDefault(); 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 }; + if (d.querySelector('[name=updates]')) patch.updates = d.querySelector('[name=updates]').checked; setLang(patch.lang); patch.ui_lang = currentLang(); saveSettings(patch); @@ -759,6 +762,64 @@ $('#settingsBtn')?.addEventListener('click', () => { d.showModal(); }); +// ------------------------------------------------------------------ atualização do app +// O núcleo do app (Tauri) consulta o `latest.json` da release no GitHub, confere a assinatura do +// pacote com a chave pública embutida, instala e reabre. No navegador (`guara ui`) não há nada a fazer. +let UPD = null; + +async function checkUpdate(manual) { + if (!tauri?.core?.invoke) return; + try { + UPD = await tauri.core.invoke('update_check'); + renderUpdateCard(); + if (manual) toast(UPD ? `Nova versão ${UPD.version} disponível` : 'Você já está na versão mais recente'); + } catch (e) { + if (manual) toast(`Não foi possível verificar atualizações: ${e}`); + } +} + +function renderUpdateCard(state) { + let el = $('#updateCard'); + 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 (state === 'busy') return; + el.innerHTML = `
Nova versão do Guará disponívelv${esc(UPD.current)} → v${esc(UPD.version)}
+ ${UPD.auto ? '' : '

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

' + esc(UPD.url) + '

'} +
+ ${UPD.auto ? '' : ''} + ${UPD.notes ? '' : ''} + +
`; + $('#updGo')?.addEventListener('click', installUpdate); + $('#updLater')?.addEventListener('click', () => { UPD = null; el.remove(); }); + $('#updNotes')?.addEventListener('click', () => info({ title: `Guará ${UPD.version}`, html: `
${esc(UPD.notes)}
` })); +} + +async function installUpdate() { + const el = $('#updateCard'); + el.innerHTML = `
Atualizando o Guará…
+

Baixando a atualização…

`; + renderUpdateCard('busy'); + try { + await tauri.core.invoke('update_install'); + } catch (e) { + toast(String(e)); + renderUpdateCard(); + } +} + +function updateProgress(p) { + const bar = $('#updBar'); const msg = $('#updMsg'); + if (!bar || !msg) return; + if (p.installing) { bar.classList.add('indet'); msg.textContent = 'Instalando… o Guará vai reabrir sozinho.'; return; } + if (p.total) { + const pct = Math.min(100, Math.round((p.downloaded / p.total) * 100)); + bar.classList.remove('indet'); + bar.firstElementChild.style.width = `${pct}%`; + msg.textContent = `Baixando… ${pct}%`; + } +} + // ------------------------------------------------------------------ fechar com varredura em andamento function askClose() { return new Promise((resolve) => { @@ -807,6 +868,8 @@ function watchClose() { setLang(SETTINGS.lang); if (SETTINGS.ui_lang !== currentLang()) saveSettings({ ui_lang: currentLang() }); watchClose(); + tauri?.event?.listen?.('update-progress', (e) => updateProgress(e.payload)); + if (SETTINGS.updates !== false) checkUpdate(false); tauri?.event?.listen?.('elevate-failed', (e) => { toast(String(e.payload || 'Não foi possível reiniciar como administrador')); }); } catch (e) { main.innerHTML = `
${warnSvg}
Não foi possível falar com o núcleo do Guará: ${esc(e.message)}. Se abriu esta página manualmente, use o endereço completo exibido pelo comando guara ui (ele inclui o token).
`; diff --git a/apps/gui/ui/i18n-en.js b/apps/gui/ui/i18n-en.js index 2bb5d69..d77c732 100644 --- a/apps/gui/ui/i18n-en.js +++ b/apps/gui/ui/i18n-en.js @@ -575,4 +575,19 @@ export const EN = { '{n} · {n}{n} · confiança {n} · {n} de {n}': '{n} · {n}{n} · confidence {n} · {n} of {n}', 'e mais {n}…': 'and {n} more…', 'abre no seu player: o navegador embutido do Linux não traz os codecs e travaria a janela.': 'opens in your player: the embedded Linux browser does not include the codecs and would freeze the window.', + 'Nova versão do Guará disponível': 'New Guará version available', + 'Atualizar agora': 'Update now', + 'Novidades': 'What\'s new', + 'Depois': 'Later', + '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.', + '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 GitHub para ver se há versão nova (uma consulta, sem enviar dados seus)': 'On startup, checks GitHub for a new version (one request, nothing of yours is sent)', + 'Verificar agora': 'Check now', + '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}', }; diff --git a/crates/guara-core/src/settings.rs b/crates/guara-core/src/settings.rs index 44e159b..af7739f 100644 --- a/crates/guara-core/src/settings.rs +++ b/crates/guara-core/src/settings.rs @@ -23,6 +23,10 @@ pub struct Settings { /// Mostrar mapas das fotos com GPS (baixa blocos do OpenStreetMap pela internet). #[serde(default)] pub maps: bool, + /// Procurar versão nova no GitHub ao abrir o app (uma consulta ao `latest.json` da release, + /// sem enviar nada além do que qualquer navegador envia). + #[serde(default = "yes")] + pub updates: bool, } fn auto() -> String { @@ -41,6 +45,7 @@ impl Default for Settings { ui_lang: None, notify: true, maps: false, + updates: true, } } } diff --git a/crates/guara-core/tests/disk.rs b/crates/guara-core/tests/disk.rs index 00d8de8..b8f4400 100644 --- a/crates/guara-core/tests/disk.rs +++ b/crates/guara-core/tests/disk.rs @@ -298,6 +298,18 @@ fn configuracoes_de_tema_persistem() { init(); let _ = std::fs::remove_file(settings::config_dir().join("settings.json")); assert_eq!(settings::load().theme, "auto"); + assert!( + settings::load().updates, + "a verificação de atualização vem ligada" + ); + // arquivo de uma versão anterior, sem o campo novo + std::fs::write( + settings::config_dir().join("settings.json"), + r#"{"theme":"dark"}"#, + ) + .unwrap(); + assert!(settings::load().updates); + let _ = std::fs::remove_file(settings::config_dir().join("settings.json")); settings::save(&settings::Settings { theme: "dark".into(), ..Default::default() diff --git a/docs/interface.md b/docs/interface.md index 131411e..16248f7 100644 --- a/docs/interface.md +++ b/docs/interface.md @@ -25,7 +25,11 @@ The switch at the bottom of the sidebar offers **Auto** (follows the system), ** ## Language -**Settings** (the gear at the bottom of the sidebar) has a **Language** selector: **Auto** follows the system language, or you can pick **Português** or **English**. The change applies right away. The same dialog controls notifications for long tasks and online maps for photos with GPS (off by default, because it downloads OpenStreetMap tiles). +**Settings** (the gear at the bottom of the sidebar) has a **Language** selector: **Auto** follows the system language, or you can pick **Português** or **English**. The change applies right away. The same dialog controls automatic updates (below), notifications for long tasks and online maps for photos with GPS (off by default, because it downloads OpenStreetMap tiles). + +## Updates + +When the desktop app opens it asks GitHub for the release's `latest.json`. If a newer version exists, a card appears at the bottom right with the versions, **What's new** (the CHANGELOG notes) and **Update now**. The app downloads the package, verifies its signature against the public key built into the app, installs and reopens by itself. On Linux this works for the AppImage (the file is replaced in place); installs from `.deb` or `.rpm` only show the notice with the release address. **Settings > Automatic updates** turns the startup check off and has a **Check now** button. The check sends nothing besides what any browser sends to GitHub. `guara ui` in the browser does not update itself.

The Settings dialog with the Language selector set to English

diff --git a/docs/pt-BR/interface.md b/docs/pt-BR/interface.md index 2d36743..efbfcee 100644 --- a/docs/pt-BR/interface.md +++ b/docs/pt-BR/interface.md @@ -25,7 +25,11 @@ O seletor no fim da barra lateral oferece **Auto** (acompanha o sistema), **Clar ## Idioma -Em **Configurações** (a engrenagem no fim da barra lateral) há o seletor **Idioma**: **Auto** acompanha o idioma do sistema, ou você escolhe **Português** ou **English**. A troca vale na hora. A mesma janela controla as notificações de tarefas longas e os mapas online das fotos com GPS (desligados por padrão, porque baixam blocos do OpenStreetMap). +Em **Configurações** (a engrenagem no fim da barra lateral) há o seletor **Idioma**: **Auto** acompanha o idioma do sistema, ou você escolhe **Português** ou **English**. A troca vale na hora. A mesma janela controla as atualizações automáticas (abaixo), as notificações de tarefas longas e os mapas online das fotos com GPS (desligados por padrão, porque baixam blocos do OpenStreetMap). + +## Atualizações + +Ao abrir, o app desktop pede ao GitHub o `latest.json` da release. Havendo versão mais nova, aparece um cartão no canto inferior direito com as versões, **Novidades** (as notas do CHANGELOG) e **Atualizar agora**. O app baixa o pacote, confere a assinatura com a chave pública embutida nele, instala e reabre sozinho. No Linux isso vale para o AppImage (o arquivo é substituído no lugar); instalações por `.deb` ou `.rpm` só mostram o aviso com o endereço da release. **Configurações > Atualizações automáticas** desliga a verificação ao abrir e tem o botão **Verificar agora**. A consulta não envia nada além do que qualquer navegador envia ao GitHub. O `guara ui` no navegador não se atualiza sozinho.

A janela de Configurações com o seletor de Idioma

diff --git a/scripts/make-latest-json.py b/scripts/make-latest-json.py new file mode 100644 index 0000000..0ca6e89 --- /dev/null +++ b/scripts/make-latest-json.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Monta o latest.json que o atualizador do app lê (Tauri updater), a partir dos pacotes e das +assinaturas `.sig` já enviados à release. + +Roda no job de publicação, depois que todos os jobs de empacotamento terminaram. Montar o arquivo +aqui, em um passo só, evita a corrida de quem tenta atualizá-lo em paralelo por plataforma. + +Uso: make-latest-json.py (usa `gh` e GH_REPO/GITHUB_REPOSITORY) +""" +import json +import os +import subprocess +import sys +import tempfile +from datetime import datetime, timezone + +# (chaves de plataforma, padrão do nome do pacote). {v} = versão sem o "v". +PLATFORMS = [ + (["linux-x86_64", "linux-x86_64-appimage"], "Guara_{v}_amd64.AppImage"), + (["windows-x86_64", "windows-x86_64-nsis"], "Guara_{v}_x64-setup.exe"), + (["windows-x86_64-msi"], "Guara_{v}_x64_en-US.msi"), + (["darwin-aarch64", "darwin-aarch64-app"], "Guara_{v}_aarch64.app.tar.gz"), + (["darwin-x86_64", "darwin-x86_64-app"], "Guara_{v}_x64.app.tar.gz"), +] + + +def build(version, notes, assets, pub_date): + """`assets` = {nome_do_pacote: (url, assinatura)}. Devolve o dicionário do latest.json. + + Plataforma sem pacote ou sem assinatura sai da lista; o chamador decide se isso é erro.""" + platforms = {} + for keys, pattern in PLATFORMS: + name = pattern.format(v=version) + if name not in assets: + continue + url, sig = assets[name] + for k in keys: + platforms.setdefault(k, {"signature": sig.strip(), "url": url}) + return {"version": version, "notes": notes.strip(), "pub_date": pub_date, "platforms": platforms} + + +def main(): + tag, notes_path, out = sys.argv[1:4] + version = tag.lstrip("v") + repo = os.environ.get("GH_REPO") or os.environ["GITHUB_REPOSITORY"] + rel = json.loads(subprocess.check_output(["gh", "release", "view", tag, "--repo", repo, "--json", "assets"])) + urls = {a["name"]: a["url"] for a in rel["assets"]} + assets = {} + with tempfile.TemporaryDirectory() as d: + for _, pattern in PLATFORMS: + name = pattern.format(v=version) + if name in urls and name + ".sig" in urls: + subprocess.check_call(["gh", "release", "download", tag, "--repo", repo, "-p", name + ".sig", "-D", d]) + assets[name] = (urls[name], open(os.path.join(d, name + ".sig")).read()) + data = build(version, open(notes_path, encoding="utf-8").read(), assets, + datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")) + # Sem o Linux (AppImage) o atualizador do dono do projeto não funciona: não publica pela metade. + for must in ("linux-x86_64", "windows-x86_64", "darwin-aarch64", "darwin-x86_64"): + if must not in data["platforms"]: + sys.exit(f"faltam o pacote ou a assinatura de {must}; confira se TAURI_SIGNING_PRIVATE_KEY está nos segredos") + with open(out, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + print(f"{out}: {sorted(data['platforms'])}") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_make_latest_json.py b/scripts/test_make_latest_json.py new file mode 100644 index 0000000..4d52ca8 --- /dev/null +++ b/scripts/test_make_latest_json.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Teste de scripts/make-latest-json.py: python3 scripts/test_make_latest_json.py""" +import importlib.util +import os +import unittest + +spec = importlib.util.spec_from_file_location("m", os.path.join(os.path.dirname(__file__), "make-latest-json.py")) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + +BASE = "https://github.com/x/y/releases/download/v1.3.0/" + + +def assets(version="1.3.0"): + names = [p.format(v=version) for _, p in m.PLATFORMS] + return {n: (BASE + n, f"assinatura-de-{n}\n") for n in names} + + +class LatestJson(unittest.TestCase): + def test_all_platforms_and_variants(self): + d = m.build("1.3.0", "### Novidades\n", assets(), "2026-10-23T12:00:00Z") + self.assertEqual(d["version"], "1.3.0") + self.assertEqual(d["notes"], "### Novidades") + p = d["platforms"] + for k in ("linux-x86_64", "linux-x86_64-appimage", "windows-x86_64", "windows-x86_64-nsis", + "windows-x86_64-msi", "darwin-aarch64", "darwin-x86_64"): + self.assertIn(k, p) + # a chave genérica do Windows aponta para o instalador NSIS, não para o MSI + self.assertTrue(p["windows-x86_64"]["url"].endswith("x64-setup.exe")) + self.assertTrue(p["windows-x86_64-msi"]["url"].endswith(".msi")) + self.assertEqual(p["linux-x86_64"]["signature"], "assinatura-de-Guara_1.3.0_amd64.AppImage") + + def test_missing_package_is_left_out(self): + a = assets() + del a["Guara_1.3.0_x64.app.tar.gz"] + d = m.build("1.3.0", "", a, "2026-10-23T12:00:00Z") + self.assertNotIn("darwin-x86_64", d["platforms"]) + self.assertIn("darwin-aarch64", d["platforms"]) + + def test_other_versions_do_not_leak(self): + d = m.build("1.3.0", "", assets("1.2.0"), "2026-10-23T12:00:00Z") + self.assertEqual(d["platforms"], {}) + + +if __name__ == "__main__": + unittest.main()