Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions app/src/lib/tauriHost.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
72 changes: 52 additions & 20 deletions app/src/lib/tauriHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -46,34 +46,66 @@ async function checkForUpdate(): Promise<AppUpdate | null> {
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<Image> | undefined;

/** Brings back what Tauri dropped before an installer that did not start. */
async function recoverFromInstaller(): Promise<void> {
overlay = undefined;
await rebuildTray();
const window = getCurrentWindow();
await window.show();
await window.setFocus();
}

export function tauriHost(): Host {
const window = getCurrentWindow();
let overlay: Promise<Image> | undefined;
return {
daemonStatus: () => invoke<DaemonStatus>("daemon_status"),
installDaemon: () => invoke<DaemonStatus>("daemon_install"),
Expand Down
21 changes: 20 additions & 1 deletion app/src/lib/tauriTray.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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([]);
});
21 changes: 21 additions & 0 deletions app/src/lib/tauriTray.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,19 +86,40 @@ async function apply(view: TrayView): Promise<void> {
menu = next;
}

let shown: TrayView | null = null;

export function showTray(view: TrayView, next: TrayActions): Promise<void> {
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<void> {
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<void> {
const run = queue.then(async () => {
await tray?.close();
await menu?.close();
tray = null;
menu = null;
shown = null;
});
queue = run.catch(() => {});
return run;
Expand Down
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<versão>_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 <sistema>` 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_<versão>_amd64.AppImage` e `.deb`, cada um com `.sig`. No macOS, `Botloft_<versão>_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 (`<so>-<arch>-<instalador>`), 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.
Expand Down
Loading