Atualização pelo próprio app (Tauri updater) com cartão de aviso
Ao abrir, o app consulta o latest.json da release no GitHub; havendo versão nova, um cartão oferece Atualizar agora: baixa, confere a assinatura com a chave pública embutida, instala e reabre (AppImage substituído no lugar; Windows e macOS pelo instalador). Instalação por .deb ou .rpm só avisa. Configurações ganha a opção de desligar a consulta e o botão Verificar agora. A release assina os pacotes com a chave guardada no ambiente 'release' do GitHub (só tags v*) e o latest.json é montado de uma vez por scripts/make-latest-json.py, depois que todos os jobs de empacotamento terminam. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
17b0e89e03
commit
daa12ba117
20 files changed
+341
-7
No files matched your search
@@ -28,6 +28,8 @@ jobs:
|
|||||||
node-version: 22
|
node-version: 22
|
||||||
- name: Cobertura da tradução da interface
|
- name: Cobertura da tradução da interface
|
||||||
run: node scripts/check-ui-i18n.mjs
|
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 fmt --all -- --check
|
||||||
- run: cargo clippy --workspace --all-targets
|
- run: cargo clippy --workspace --all-targets
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ jobs:
|
|||||||
name: App desktop (${{ matrix.name }})
|
name: App desktop (${{ matrix.name }})
|
||||||
needs: create-release
|
needs: create-release
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
# Os segredos de assinatura do atualizador ficam no ambiente "release", que só aceita tags v*.
|
||||||
|
environment: release
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -118,13 +120,19 @@ jobs:
|
|||||||
run: scripts/install-tauri-deps.sh
|
run: scripts/install-tauri-deps.sh
|
||||||
- name: Gera os ícones
|
- 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
|
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
|
- uses: tauri-apps/tauri-action@v1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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:
|
with:
|
||||||
projectPath: apps/gui/src-tauri
|
projectPath: apps/gui/src-tauri
|
||||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||||
args: ${{ matrix.args }}
|
args: ${{ matrix.args }}
|
||||||
|
# o latest.json é montado no job de publicação, de uma vez só (scripts/make-latest-json.py)
|
||||||
|
uploadUpdaterJson: false
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
name: Publica a release
|
name: Publica a release
|
||||||
@@ -149,3 +157,11 @@ jobs:
|
|||||||
body: fs.readFileSync('notes.md', 'utf8'),
|
body: fs.readFileSync('notes.md', 'utf8'),
|
||||||
draft: false,
|
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
|
||||||
@@ -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.
|
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
|
## 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).
|
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).
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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).
|
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).
|
||||||
|
|||||||
@@ -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+).
|
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.
|
> 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.
|
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.
|
||||||
|
|||||||
@@ -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+).
|
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.
|
> 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.
|
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.
|
||||||
|
|||||||
+1
-1
@@ -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.
|
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.
|
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.
|
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
|
## History
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.
|
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.
|
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.
|
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
|
## Histórico
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ tauri-build = { version = "2", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
guara-core = { path = "../../../crates/guara-core" }
|
guara-core = { path = "../../../crates/guara-core" }
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,73 @@ async fn rpc(state: tauri::State<'_, State>, method: String, params: serde_json:
|
|||||||
.map_err(|e| e.to_string())?
|
.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<Option<UpdateInfo>, 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
|
/// 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
|
/// 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.
|
/// autorização falhar, a janela continua aqui e a interface mostra o motivo.
|
||||||
@@ -155,6 +222,7 @@ fn main() {
|
|||||||
let for_protocol = engine.clone();
|
let for_protocol = engine.clone();
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.manage(State(engine))
|
.manage(State(engine))
|
||||||
.register_asynchronous_uri_scheme_protocol("guara", move |_ctx, request, responder| {
|
.register_asynchronous_uri_scheme_protocol("guara", move |_ctx, request, responder| {
|
||||||
let eng = for_protocol.clone();
|
let eng = for_protocol.clone();
|
||||||
@@ -176,7 +244,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("erro ao iniciar o Guará");
|
.expect("erro ao iniciar o Guará");
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,18 @@
|
|||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
]
|
],
|
||||||
|
"createUpdaterArtifacts": true
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDhENzRCRUU5NEI3QTAxQzgKUldUSUFYcEw2YjUwamFzMnpXM3ZKTEZPN1JIeXdLL3Q5SEF1M1FxQ284WFM0UWNsdlAyNUloWTEK",
|
||||||
|
"endpoints": [
|
||||||
|
"https://github.com/lucasrguerra/guara/releases/latest/download/latest.json"
|
||||||
|
],
|
||||||
|
"windows": {
|
||||||
|
"installMode": "passive"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -518,3 +518,12 @@ dialog.lightbox[open] { display: grid; }
|
|||||||
|
|
||||||
/* logo inline: segue o tema escolhido no app, não o do sistema */
|
/* 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); }
|
.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; }
|
||||||
+64
-1
@@ -53,7 +53,7 @@ document.querySelectorAll('[data-theme-pick]').forEach((b) => (b.onclick = async
|
|||||||
|
|
||||||
// ------------------------------------------------------------------ configurações
|
// ------------------------------------------------------------------ configurações
|
||||||
// (declarado antes do uso: `S` é definido mais abaixo, por isso o objeto fica aqui)
|
// (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) {
|
async function saveSettings(patch) {
|
||||||
Object.assign(SETTINGS, patch);
|
Object.assign(SETTINGS, patch);
|
||||||
try { await rpc('settings_set', SETTINGS); } catch { /* segue só com o armazenamento local */ }
|
try { await rpc('settings_set', SETTINGS); } catch { /* segue só com o armazenamento local */ }
|
||||||
@@ -745,11 +745,14 @@ $('#settingsBtn')?.addEventListener('click', () => {
|
|||||||
<label class="fld"><span>Idioma</span><select class="select" name="lang"><option value="auto">Auto</option><option value="pt">Português</option><option value="en">English</option></select></label>
|
<label class="fld"><span>Idioma</span><select class="select" name="lang"><option value="auto">Auto</option><option value="pt">Português</option><option value="en">English</option></select></label>
|
||||||
<label class="check"><input type="checkbox" name="notify" ${SETTINGS.notify ? 'checked' : ''}><span><b>Notificações</b><br><span class="sub">Avisar quando uma tarefa longa terminar</span></span></label>
|
<label class="check"><input type="checkbox" name="notify" ${SETTINGS.notify ? 'checked' : ''}><span><b>Notificações</b><br><span class="sub">Avisar quando uma tarefa longa terminar</span></span></label>
|
||||||
<label class="check"><input type="checkbox" name="maps" ${SETTINGS.maps ? 'checked' : ''}><span><b>Mapas online</b><br><span class="sub">Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)</span></span></label>
|
<label class="check"><input type="checkbox" name="maps" ${SETTINGS.maps ? 'checked' : ''}><span><b>Mapas online</b><br><span class="sub">Mostrar mapas das fotos com GPS (usa a internet: baixa blocos do OpenStreetMap)</span></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 GitHub 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.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 };
|
||||||
|
if (d.querySelector('[name=updates]')) patch.updates = d.querySelector('[name=updates]').checked;
|
||||||
setLang(patch.lang);
|
setLang(patch.lang);
|
||||||
patch.ui_lang = currentLang();
|
patch.ui_lang = currentLang();
|
||||||
saveSettings(patch);
|
saveSettings(patch);
|
||||||
@@ -759,6 +762,64 @@ $('#settingsBtn')?.addEventListener('click', () => {
|
|||||||
d.showModal();
|
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 = `<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>
|
||||||
|
${UPD.auto ? '' : '<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>'}
|
||||||
|
<div class="upd-actions">
|
||||||
|
${UPD.auto ? '<button class="btn primary sm" id="updGo">Atualizar agora</button>' : ''}
|
||||||
|
${UPD.notes ? '<button class="btn sm" id="updNotes">Novidades</button>' : ''}
|
||||||
|
<button class="btn sm" id="updLater">Depois</button>
|
||||||
|
</div>`;
|
||||||
|
$('#updGo')?.addEventListener('click', installUpdate);
|
||||||
|
$('#updLater')?.addEventListener('click', () => { UPD = null; el.remove(); });
|
||||||
|
$('#updNotes')?.addEventListener('click', () => info({ title: `Guará ${UPD.version}`, html: `<pre class="upd-notes">${esc(UPD.notes)}</pre>` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installUpdate() {
|
||||||
|
const el = $('#updateCard');
|
||||||
|
el.innerHTML = `<div class="upd-head"><b>Atualizando o Guará…</b></div>
|
||||||
|
<div class="bar indet" id="updBar"><i style="width:0"></i></div><p class="sub" id="updMsg">Baixando a atualização…</p>`;
|
||||||
|
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
|
// ------------------------------------------------------------------ fechar com varredura em andamento
|
||||||
function askClose() {
|
function askClose() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -807,6 +868,8 @@ function watchClose() {
|
|||||||
setLang(SETTINGS.lang);
|
setLang(SETTINGS.lang);
|
||||||
if (SETTINGS.ui_lang !== currentLang()) saveSettings({ ui_lang: currentLang() });
|
if (SETTINGS.ui_lang !== currentLang()) saveSettings({ ui_lang: currentLang() });
|
||||||
watchClose();
|
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')); });
|
tauri?.event?.listen?.('elevate-failed', (e) => { toast(String(e.payload || 'Não foi possível reiniciar como administrador')); });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
main.innerHTML = `<div class="page"><div class="notice bad">${warnSvg}<div>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 <code>guara ui</code> (ele inclui o token).</div></div></div>`;
|
main.innerHTML = `<div class="page"><div class="notice bad">${warnSvg}<div>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 <code>guara ui</code> (ele inclui o token).</div></div></div>`;
|
||||||
|
|||||||
@@ -575,4 +575,19 @@ export const EN = {
|
|||||||
'{n} · {n}{n} · confiança {n} · {n} de {n}': '{n} · {n}{n} · confidence {n} · {n} of {n}',
|
'{n} · {n}{n} · confiança {n} · {n} de {n}': '{n} · {n}{n} · confidence {n} · {n} of {n}',
|
||||||
'e mais {n}…': 'and {n} more…',
|
'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.',
|
'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}',
|
||||||
};
|
};
|
||||||
@@ -23,6 +23,10 @@ pub struct Settings {
|
|||||||
/// Mostrar mapas das fotos com GPS (baixa blocos do OpenStreetMap pela internet).
|
/// Mostrar mapas das fotos com GPS (baixa blocos do OpenStreetMap pela internet).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub maps: bool,
|
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 {
|
fn auto() -> String {
|
||||||
@@ -41,6 +45,7 @@ impl Default for Settings {
|
|||||||
ui_lang: None,
|
ui_lang: None,
|
||||||
notify: true,
|
notify: true,
|
||||||
maps: false,
|
maps: false,
|
||||||
|
updates: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,6 +298,18 @@ fn configuracoes_de_tema_persistem() {
|
|||||||
init();
|
init();
|
||||||
let _ = std::fs::remove_file(settings::config_dir().join("settings.json"));
|
let _ = std::fs::remove_file(settings::config_dir().join("settings.json"));
|
||||||
assert_eq!(settings::load().theme, "auto");
|
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 {
|
settings::save(&settings::Settings {
|
||||||
theme: "dark".into(),
|
theme: "dark".into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
+5
-1
@@ -25,7 +25,11 @@ The switch at the bottom of the sidebar offers **Auto** (follows the system), **
|
|||||||
|
|
||||||
## Language
|
## 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.
|
||||||
|
|
||||||
<p align="center"><img src="img/settings-language-en.png" width="420" alt="The Settings dialog with the Language selector set to English"></p>
|
<p align="center"><img src="img/settings-language-en.png" width="420" alt="The Settings dialog with the Language selector set to English"></p>
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ O seletor no fim da barra lateral oferece **Auto** (acompanha o sistema), **Clar
|
|||||||
|
|
||||||
## Idioma
|
## 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.
|
||||||
|
|
||||||
<p align="center"><img src="../img/settings-language-pt.png" width="420" alt="A janela de Configurações com o seletor de Idioma"></p>
|
<p align="center"><img src="../img/settings-language-pt.png" width="420" alt="A janela de Configurações com o seletor de Idioma"></p>
|
||||||
|
|
||||||
|
|||||||
@@ -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 <tag> <notas.md> <saida.json> (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()
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user