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.