From 6a167c3f66dbe22e7447dfa62039383f09583e06 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:47:50 -0300 Subject: [PATCH] fix(app): retry an update whose installer did not start Before it runs the Windows installer, the updater plugin calls cleanup_before_exit: it hides the window, drops the tray icon and frees every resource the page holds, the found update included. When the installer then fails to start (UAC declined, an antivirus blocks it), the app keeps running without them, and Try again failed with "The resource id ... is invalid". After a failed install, show the window, rebuild the tray icon and look for the update again on the next try. Spec 15.5 records why. --- app/src/lib/tauriHost.test.ts | 59 ++++++++++++++++++++++++++++ app/src/lib/tauriHost.ts | 72 +++++++++++++++++++++++++---------- app/src/lib/tauriTray.test.ts | 21 +++++++++- app/src/lib/tauriTray.ts | 21 ++++++++++ docs/spec.md | 2 +- 5 files changed, 153 insertions(+), 22 deletions(-) create mode 100644 app/src/lib/tauriHost.test.ts diff --git a/app/src/lib/tauriHost.test.ts b/app/src/lib/tauriHost.test.ts new file mode 100644 index 00000000..7666f46e --- /dev/null +++ b/app/src/lib/tauriHost.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, expect, test, vi } from "vitest"; + +// Each `check` makes a new update; the first one's installer fails to start. +const made: { rid: number; closed: boolean }[] = []; +const rebuilt = vi.fn(async () => {}); +const appWindow = { show: vi.fn(async () => {}), setFocus: vi.fn(async () => {}) }; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: async () => {} })); +vi.mock("@tauri-apps/api/event", () => ({ listen: async () => () => {} })); +vi.mock("@tauri-apps/api/image", () => ({ Image: {} })); +vi.mock("@tauri-apps/api/webview", () => ({ getCurrentWebview: () => ({}) })); +vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => appWindow })); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: async () => null })); +vi.mock("@tauri-apps/plugin-notification", () => ({})); +vi.mock("./tauriTray", () => ({ + showTray: async () => {}, + hideTray: async () => {}, + rebuildTray: rebuilt, +})); +vi.mock("@tauri-apps/plugin-updater", () => ({ + check: async () => { + const handle = { rid: made.length + 1, closed: false }; + made.push(handle); + return { + version: "0.9.0", + body: "", + downloadAndInstall: async () => { + if (handle.closed) { + throw new Error(`The resource id ${handle.rid} is invalid.`); + } + if (handle.rid === 1) { + handle.closed = true; + throw new Error("The operation was canceled by the user. (os error 1223)"); + } + }, + close: async () => { + handle.closed = true; + }, + }; + }, +})); + +beforeEach(() => { + made.length = 0; + vi.stubEnv("DEV", false); +}); + +test("a retry after an installer that did not start looks for the update again", async () => { + const { tauriHost } = await import("./tauriHost"); + const update = await tauriHost().checkForUpdate(); + if (!update) { + throw new Error("expected an update"); + } + await expect(update.install(() => {})).rejects.toThrow("os error 1223"); + expect(rebuilt).toHaveBeenCalled(); + expect(appWindow.show).toHaveBeenCalled(); + await update.install(() => {}); + expect(made).toHaveLength(2); +}); diff --git a/app/src/lib/tauriHost.ts b/app/src/lib/tauriHost.ts index 7ebe42af..1149ac44 100644 --- a/app/src/lib/tauriHost.ts +++ b/app/src/lib/tauriHost.ts @@ -11,9 +11,9 @@ import { requestPermission, sendNotification, } from "@tauri-apps/plugin-notification"; -import { check } from "@tauri-apps/plugin-updater"; +import { check, type Update } from "@tauri-apps/plugin-updater"; import type { AppUpdate, DaemonStatus, Host } from "./host"; -import { hideTray, showTray } from "./tauriTray"; +import { hideTray, rebuildTray, showTray } from "./tauriTray"; const DOT = 16; @@ -46,34 +46,66 @@ async function checkForUpdate(): Promise { if (!update) { return null; } + let pending: Update | null = update; return { version: update.version, notes: update.body?.trim() || null, install: async (progress) => { - let total = 0; - let done = 0; - await update.downloadAndInstall((event) => { - switch (event.event) { - case "Started": - total = event.data.contentLength ?? 0; - progress(total > 0 ? 0 : null); - break; - case "Progress": - done += event.data.chunkLength; - progress(total > 0 ? Math.min(1, done / total) : null); - break; - case "Finished": - progress(1); - break; - } - }); + pending ??= await check(); + if (!pending) { + throw new Error("The update is no longer offered."); + } + const current = pending; + try { + await install(current, progress); + } catch (error) { + // Right before it runs the installer, Tauri drops every handle this + // page holds and hides the window. When the installer then fails to + // start (the owner said no to Windows, an antivirus stopped it), the + // app keeps running without them: look again on the next try. + pending = null; + await current.close().catch(() => {}); + await recoverFromInstaller().catch(() => {}); + throw error; + } }, }; } +async function install(update: Update, progress: (fraction: number | null) => void) { + let total = 0; + let done = 0; + await update.downloadAndInstall((event) => { + switch (event.event) { + case "Started": + total = event.data.contentLength ?? 0; + progress(total > 0 ? 0 : null); + break; + case "Progress": + done += event.data.chunkLength; + progress(total > 0 ? Math.min(1, done / total) : null); + break; + case "Finished": + progress(1); + break; + } + }); +} + +/** The taskbar dot, made once; Tauri may drop it with the rest (above). */ +let overlay: Promise | undefined; + +/** Brings back what Tauri dropped before an installer that did not start. */ +async function recoverFromInstaller(): Promise { + overlay = undefined; + await rebuildTray(); + const window = getCurrentWindow(); + await window.show(); + await window.setFocus(); +} + export function tauriHost(): Host { const window = getCurrentWindow(); - let overlay: Promise | undefined; return { daemonStatus: () => invoke("daemon_status"), installDaemon: () => invoke("daemon_install"), diff --git a/app/src/lib/tauriTray.test.ts b/app/src/lib/tauriTray.test.ts index 38ef5159..ff422127 100644 --- a/app/src/lib/tauriTray.test.ts +++ b/app/src/lib/tauriTray.test.ts @@ -14,7 +14,14 @@ vi.mock("@tauri-apps/api/tray", () => ({ TrayIcon: { new: async ({ id }: { id: string }) => { shown.push(id); - return { setMenu: async () => {}, setTooltip: async () => {}, setIcon: async () => {} }; + return { + setMenu: async () => {}, + setTooltip: async () => {}, + setIcon: async () => {}, + close: async () => { + shown.splice(shown.indexOf(id), 1); + }, + }; }, getById: async (id: string) => (shown.includes(id) ? {} : null), removeById: async (id: string) => { @@ -52,3 +59,15 @@ test("a reloaded page replaces the icon it left behind", async () => { await after.showTray(view, actions); expect(shown).toEqual(["botloft"]); }); + +test("an icon Tauri dropped comes back with the last view", async () => { + const { hideTray, rebuildTray, showTray } = await import("./tauriTray"); + await showTray(view, actions); + shown.length = 0; + await rebuildTray(); + expect(shown).toEqual(["botloft"]); + await hideTray(); + shown.length = 0; + await rebuildTray(); + expect(shown).toEqual([]); +}); diff --git a/app/src/lib/tauriTray.ts b/app/src/lib/tauriTray.ts index 46685b81..04524c13 100644 --- a/app/src/lib/tauriTray.ts +++ b/app/src/lib/tauriTray.ts @@ -86,19 +86,40 @@ async function apply(view: TrayView): Promise { menu = next; } +let shown: TrayView | null = null; + export function showTray(view: TrayView, next: TrayActions): Promise { actions = next; + shown = view; const run = queue.then(() => apply(view)); queue = run.catch(() => {}); return run; } +/** + * Makes the icon again after Tauri dropped it and everything this page held + * (an update that started its installer and then failed to run it). + */ +export function rebuildTray(): Promise { + const run = queue.then(async () => { + tray = null; + menu = null; + icons.clear(); + if (shown) { + await apply(shown); + } + }); + queue = run.catch(() => {}); + return run; +} + export function hideTray(): Promise { const run = queue.then(async () => { await tray?.close(); await menu?.close(); tray = null; menu = null; + shown = null; }); queue = run.catch(() => {}); return run; diff --git a/docs/spec.md b/docs/spec.md index b8873b93..3f12c9f4 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -880,7 +880,7 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a ### 15.5 Atualizações - O app usa o `tauri-plugin-updater`. O feed é `latest.json` do último release publicado no GitHub (`releases/latest/download/latest.json`). O app o consulta ao abrir e a cada 6 h; sem rede ou sem release, fica quieto. Build de dev não consulta, para não se trocar pelo app publicado. -- Com versão nova, aparece "Update available" na barra de título. Um clique abre o diálogo: a versão, "What's new" (se o release tiver notas) e o aviso de que o Botloft fecha, instala e abre de novo, e de que os bots pausam por um instante e continuam de onde pararam. "Update now" baixa com progresso e roda o instalador; se falhar, o erro fica em "Details" e dá para tentar de novo. +- Com versão nova, aparece "Update available" na barra de título. Um clique abre o diálogo: a versão, "What's new" (se o release tiver notas) e o aviso de que o Botloft fecha, instala e abre de novo, e de que os bots pausam por um instante e continuam de onde pararam. "Update now" baixa com progresso e roda o instalador; se falhar, o erro fica em "Details" e dá para tentar de novo. Antes de rodar o instalador no Windows, o plugin chama `cleanup_before_exit`: esconde a janela, tira o ícone perto do relógio e solta todo recurso que a página guarda, inclusive a atualização achada. Se o instalador não chega a abrir (o dono recusa o UAC, um antivírus o barra), o app segue vivo sem nada disso; por isso, depois de uma falha, o app mostra a janela, refaz o ícone e a próxima tentativa consulta o feed de novo. Sem isso, a segunda tentativa falhava com "The resource id … is invalid" (visto num PC com a 0.9.0, 2026-10-02). - O feed aponta para o instalador NSIS do release, `Botloft__x64-update.exe`, e não para a tela de instalação (15.7). O instalador roda como `/P /UPDATE /R`: passivo, sem perguntas, e reabre o app. O hook de desinstalação não faz nada com `/UPDATE` (15.4), o daemon segue rodando, e o app reaberto o atualiza porque ele ficou `outdated` (15.2). - Os artefatos são assinados com a chave do updater do Tauri (`createUpdaterArtifacts`), e a chave pública fica no `tauri.conf.json`. `requireSignedVersion` exige que a assinatura traga a versão, para um feed adulterado não empurrar uma versão antiga de volta. O instalador não tem assinatura Authenticode, então o SmartScreen avisa na primeira execução. - Release: a versão fica só no `[workspace.package]` do `Cargo.toml` (o `tauri.conf.json` não repete a versão e usa a do crate). Um push de tag `vX.Y.Z` roda `.github/workflows/release.yml`. Um job por sistema (Windows, Ubuntu 22.04 e macOS Apple Silicon) confere tag e versão, roda `pnpm bundle` com `TAURI_SIGNING_PRIVATE_KEY` (segredo do repositório), no Windows também `pnpm bundle:setup` (15.7), depois `release.mjs stage ` e o atestado de origem dos seus arquivos, e os guarda como artefato do workflow. O job final junta os três e roda `release.mjs publish`, que abre um release **rascunho** com tudo e um `latest.json` só. No Windows são a tela de instalação (`-setup.exe`) e o instalador do atualizador (`-update.exe`, com o `.sig`). No Linux, `Botloft__amd64.AppImage` e `.deb`, cada um com `.sig`. No macOS, `Botloft__aarch64.dmg` e o `.app.tar.gz` que o updater instala, com `.sig`. O `latest.json` tem `windows-x86_64`, `linux-x86_64-appimage`, `linux-x86_64-deb` e `darwin-aarch64`: o plugin procura primeiro a chave com o tipo de instalação (`--`), então quem instalou o `.deb` recebe o `.deb` (instalado com `dpkg -i`) e quem usa o AppImage recebe o AppImage. O updater só enxerga o release depois que o dono o publica. Rodado à mão (`workflow_dispatch`), o workflow faz os mesmos arquivos e os deixa como artefatos, sem conferir a tag, sem atestado e sem release.