Skip to content

Commit 9cbf6b7

Browse files
authored
Merge pull request #130 from httpsphl/fix/update-retry
fix(app): retry an update whose installer did not start
2 parents 0214bd8 + 6a167c3 commit 9cbf6b7

5 files changed

Lines changed: 153 additions & 22 deletions

File tree

‎app/src/lib/tauriHost.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { beforeEach, expect, test, vi } from "vitest";
2+
3+
// Each `check` makes a new update; the first one's installer fails to start.
4+
const made: { rid: number; closed: boolean }[] = [];
5+
const rebuilt = vi.fn(async () => {});
6+
const appWindow = { show: vi.fn(async () => {}), setFocus: vi.fn(async () => {}) };
7+
8+
vi.mock("@tauri-apps/api/core", () => ({ invoke: async () => {} }));
9+
vi.mock("@tauri-apps/api/event", () => ({ listen: async () => () => {} }));
10+
vi.mock("@tauri-apps/api/image", () => ({ Image: {} }));
11+
vi.mock("@tauri-apps/api/webview", () => ({ getCurrentWebview: () => ({}) }));
12+
vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => appWindow }));
13+
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: async () => null }));
14+
vi.mock("@tauri-apps/plugin-notification", () => ({}));
15+
vi.mock("./tauriTray", () => ({
16+
showTray: async () => {},
17+
hideTray: async () => {},
18+
rebuildTray: rebuilt,
19+
}));
20+
vi.mock("@tauri-apps/plugin-updater", () => ({
21+
check: async () => {
22+
const handle = { rid: made.length + 1, closed: false };
23+
made.push(handle);
24+
return {
25+
version: "0.9.0",
26+
body: "",
27+
downloadAndInstall: async () => {
28+
if (handle.closed) {
29+
throw new Error(`The resource id ${handle.rid} is invalid.`);
30+
}
31+
if (handle.rid === 1) {
32+
handle.closed = true;
33+
throw new Error("The operation was canceled by the user. (os error 1223)");
34+
}
35+
},
36+
close: async () => {
37+
handle.closed = true;
38+
},
39+
};
40+
},
41+
}));
42+
43+
beforeEach(() => {
44+
made.length = 0;
45+
vi.stubEnv("DEV", false);
46+
});
47+
48+
test("a retry after an installer that did not start looks for the update again", async () => {
49+
const { tauriHost } = await import("./tauriHost");
50+
const update = await tauriHost().checkForUpdate();
51+
if (!update) {
52+
throw new Error("expected an update");
53+
}
54+
await expect(update.install(() => {})).rejects.toThrow("os error 1223");
55+
expect(rebuilt).toHaveBeenCalled();
56+
expect(appWindow.show).toHaveBeenCalled();
57+
await update.install(() => {});
58+
expect(made).toHaveLength(2);
59+
});

‎app/src/lib/tauriHost.ts‎

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import {
1111
requestPermission,
1212
sendNotification,
1313
} from "@tauri-apps/plugin-notification";
14-
import { check } from "@tauri-apps/plugin-updater";
14+
import { check, type Update } from "@tauri-apps/plugin-updater";
1515
import type { AppUpdate, DaemonStatus, Host } from "./host";
16-
import { hideTray, showTray } from "./tauriTray";
16+
import { hideTray, rebuildTray, showTray } from "./tauriTray";
1717

1818
const DOT = 16;
1919

@@ -46,34 +46,66 @@ async function checkForUpdate(): Promise<AppUpdate | null> {
4646
if (!update) {
4747
return null;
4848
}
49+
let pending: Update | null = update;
4950
return {
5051
version: update.version,
5152
notes: update.body?.trim() || null,
5253
install: async (progress) => {
53-
let total = 0;
54-
let done = 0;
55-
await update.downloadAndInstall((event) => {
56-
switch (event.event) {
57-
case "Started":
58-
total = event.data.contentLength ?? 0;
59-
progress(total > 0 ? 0 : null);
60-
break;
61-
case "Progress":
62-
done += event.data.chunkLength;
63-
progress(total > 0 ? Math.min(1, done / total) : null);
64-
break;
65-
case "Finished":
66-
progress(1);
67-
break;
68-
}
69-
});
54+
pending ??= await check();
55+
if (!pending) {
56+
throw new Error("The update is no longer offered.");
57+
}
58+
const current = pending;
59+
try {
60+
await install(current, progress);
61+
} catch (error) {
62+
// Right before it runs the installer, Tauri drops every handle this
63+
// page holds and hides the window. When the installer then fails to
64+
// start (the owner said no to Windows, an antivirus stopped it), the
65+
// app keeps running without them: look again on the next try.
66+
pending = null;
67+
await current.close().catch(() => {});
68+
await recoverFromInstaller().catch(() => {});
69+
throw error;
70+
}
7071
},
7172
};
7273
}
7374

75+
async function install(update: Update, progress: (fraction: number | null) => void) {
76+
let total = 0;
77+
let done = 0;
78+
await update.downloadAndInstall((event) => {
79+
switch (event.event) {
80+
case "Started":
81+
total = event.data.contentLength ?? 0;
82+
progress(total > 0 ? 0 : null);
83+
break;
84+
case "Progress":
85+
done += event.data.chunkLength;
86+
progress(total > 0 ? Math.min(1, done / total) : null);
87+
break;
88+
case "Finished":
89+
progress(1);
90+
break;
91+
}
92+
});
93+
}
94+
95+
/** The taskbar dot, made once; Tauri may drop it with the rest (above). */
96+
let overlay: Promise<Image> | undefined;
97+
98+
/** Brings back what Tauri dropped before an installer that did not start. */
99+
async function recoverFromInstaller(): Promise<void> {
100+
overlay = undefined;
101+
await rebuildTray();
102+
const window = getCurrentWindow();
103+
await window.show();
104+
await window.setFocus();
105+
}
106+
74107
export function tauriHost(): Host {
75108
const window = getCurrentWindow();
76-
let overlay: Promise<Image> | undefined;
77109
return {
78110
daemonStatus: () => invoke<DaemonStatus>("daemon_status"),
79111
installDaemon: () => invoke<DaemonStatus>("daemon_install"),

‎app/src/lib/tauriTray.test.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ vi.mock("@tauri-apps/api/tray", () => ({
1414
TrayIcon: {
1515
new: async ({ id }: { id: string }) => {
1616
shown.push(id);
17-
return { setMenu: async () => {}, setTooltip: async () => {}, setIcon: async () => {} };
17+
return {
18+
setMenu: async () => {},
19+
setTooltip: async () => {},
20+
setIcon: async () => {},
21+
close: async () => {
22+
shown.splice(shown.indexOf(id), 1);
23+
},
24+
};
1825
},
1926
getById: async (id: string) => (shown.includes(id) ? {} : null),
2027
removeById: async (id: string) => {
@@ -52,3 +59,15 @@ test("a reloaded page replaces the icon it left behind", async () => {
5259
await after.showTray(view, actions);
5360
expect(shown).toEqual(["botloft"]);
5461
});
62+
63+
test("an icon Tauri dropped comes back with the last view", async () => {
64+
const { hideTray, rebuildTray, showTray } = await import("./tauriTray");
65+
await showTray(view, actions);
66+
shown.length = 0;
67+
await rebuildTray();
68+
expect(shown).toEqual(["botloft"]);
69+
await hideTray();
70+
shown.length = 0;
71+
await rebuildTray();
72+
expect(shown).toEqual([]);
73+
});

‎app/src/lib/tauriTray.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,19 +86,40 @@ async function apply(view: TrayView): Promise<void> {
8686
menu = next;
8787
}
8888

89+
let shown: TrayView | null = null;
90+
8991
export function showTray(view: TrayView, next: TrayActions): Promise<void> {
9092
actions = next;
93+
shown = view;
9194
const run = queue.then(() => apply(view));
9295
queue = run.catch(() => {});
9396
return run;
9497
}
9598

99+
/**
100+
* Makes the icon again after Tauri dropped it and everything this page held
101+
* (an update that started its installer and then failed to run it).
102+
*/
103+
export function rebuildTray(): Promise<void> {
104+
const run = queue.then(async () => {
105+
tray = null;
106+
menu = null;
107+
icons.clear();
108+
if (shown) {
109+
await apply(shown);
110+
}
111+
});
112+
queue = run.catch(() => {});
113+
return run;
114+
}
115+
96116
export function hideTray(): Promise<void> {
97117
const run = queue.then(async () => {
98118
await tray?.close();
99119
await menu?.close();
100120
tray = null;
101121
menu = null;
122+
shown = null;
102123
});
103124
queue = run.catch(() => {});
104125
return run;

‎docs/spec.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -880,7 +880,7 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a
880880
### 15.5 Atualizações
881881

882882
- 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.
883-
- 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.
883+
- 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).
884884
- 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).
885885
- 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.
886886
- 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.

0 commit comments

Comments
 (0)