diff --git a/app/src/lib/tauriHost.ts b/app/src/lib/tauriHost.ts index 143b0666..dfaedb2d 100644 --- a/app/src/lib/tauriHost.ts +++ b/app/src/lib/tauriHost.ts @@ -89,7 +89,9 @@ export function tauriHost(): Host { } sendNotification({ title, body, ...(sound ? { sound: "Default" } : {}) }); }, - showTray, + // A dev build runs beside the installed app: its icon says which it is. + showTray: (view, actions) => + showTray(import.meta.env.DEV ? { ...view, tooltip: `${view.tooltip} (dev)` } : view, actions), hideTray, signInToClaude: (path) => invoke("claude_sign_in", { path }), setZoom: (factor) => getCurrentWebview().setZoom(factor), diff --git a/app/src/lib/tauriTray.test.ts b/app/src/lib/tauriTray.test.ts new file mode 100644 index 00000000..38ef5159 --- /dev/null +++ b/app/src/lib/tauriTray.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, expect, test, vi } from "vitest"; +import type { TrayView } from "./host"; + +// The icons Windows shows, which outlive a page reload. +const shown: string[] = []; + +vi.mock("@tauri-apps/api/app", () => ({ defaultWindowIcon: async () => null })); +vi.mock("@tauri-apps/api/image", () => ({ Image: {} })); +vi.mock("@tauri-apps/api/menu", () => { + const item = { new: async () => ({ close: async () => {} }) }; + return { Menu: item, MenuItem: item, PredefinedMenuItem: item }; +}); +vi.mock("@tauri-apps/api/tray", () => ({ + TrayIcon: { + new: async ({ id }: { id: string }) => { + shown.push(id); + return { setMenu: async () => {}, setTooltip: async () => {}, setIcon: async () => {} }; + }, + getById: async (id: string) => (shown.includes(id) ? {} : null), + removeById: async (id: string) => { + shown.splice(shown.indexOf(id), 1); + }, + }, +})); + +const view: TrayView = { + status: "No bot working", + tooltip: "Botloft: No bot working", + open: "Open", + pause: "Pause", + quit: "Quit", + attention: false, +}; +const actions = { open: () => {}, pause: () => {}, quit: () => {} }; + +beforeEach(() => { + shown.length = 0; + vi.resetModules(); +}); + +test("two updates keep one icon", async () => { + const { showTray } = await import("./tauriTray"); + await Promise.all([showTray(view, actions), showTray(view, actions)]); + expect(shown).toEqual(["botloft"]); +}); + +test("a reloaded page replaces the icon it left behind", async () => { + const before = await import("./tauriTray"); + await before.showTray(view, actions); + vi.resetModules(); + const after = await import("./tauriTray"); + await after.showTray(view, actions); + expect(shown).toEqual(["botloft"]); +}); diff --git a/app/src/lib/tauriTray.ts b/app/src/lib/tauriTray.ts index eabc0575..46685b81 100644 --- a/app/src/lib/tauriTray.ts +++ b/app/src/lib/tauriTray.ts @@ -7,6 +7,8 @@ import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu"; import { TrayIcon } from "@tauri-apps/api/tray"; import type { TrayActions, TrayView } from "./host"; +const TRAY_ID = "botloft"; + let queue: Promise = Promise.resolve(); let tray: TrayIcon | null = null; let menu: Menu | null = null; @@ -62,8 +64,13 @@ async function apply(view: TrayView): Promise { await tray.setTooltip(view.tooltip); await tray.setIcon(image); } else { + // A reloaded page forgets the icon it made, but the icon stays near the + // clock: take it away before making this page's own. + while (await TrayIcon.getById(TRAY_ID)) { + await TrayIcon.removeById(TRAY_ID); + } tray = await TrayIcon.new({ - id: "botloft", + id: TRAY_ID, menu: next, tooltip: view.tooltip, showMenuOnLeftClick: false, diff --git a/docs/spec.md b/docs/spec.md index 6a473ef0..61633da0 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -805,7 +805,7 @@ Layout, como um app de mensagens: | `open_at_sign_in` | grava ou apaga o valor `Botloft` na chave Run do usuário (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`) com `"" --autostart`, entre aspas por causa de caminhos com espaço (o `tauri-plugin-autostart` grava sem aspas). Ligando, também libera o app se ele estava desligado nos apps de inicialização do Gerenciador de Tarefas (`StartupApproved\Run`). Build de dev nunca se registra. O app liga quando os bots iniciam com o Windows e o dono quer o Botloft perto do relógio ou a janela aberta, e desliga senão, a cada mudança. No Linux é a entrada `~/.config/autostart/botloft.desktop` (`Exec` com o app entre aspas, `"`, `` ` ``, `$` e `\` escapados e `%` dobrado); no macOS, o launch agent `~/Library/LaunchAgents/io.github.httpsphl.botloft.app.plist` (`RunAtLoad`, `LimitLoadToSessionType = Aqua`). Desligar apaga o arquivo | | `launched_at_sign_in` | se o app foi aberto com `--autostart` | | uma cópia só | `tauri-plugin-single-instance`, só em build de release (a de dev roda ao lado da instalada): abrir o Botloft de novo, pelo menu Iniciar ou por um aviso, traz a janela da cópia que roda e manda `botloft://reopened` ao app | -| ícone perto do relógio | não é comando próprio: `TrayIcon` e `Menu` do Tauri (feature `tray-icon`, `core:tray` e `core:menu` do `core:default`, e `core:app:allow-default-window-icon`), com as mudanças em fila para nunca criar dois ícones | +| ícone perto do relógio | não é comando próprio: `TrayIcon` e `Menu` do Tauri (feature `tray-icon`, `core:tray` e `core:menu` do `core:default`, e `core:app:allow-default-window-icon`), com as mudanças em fila para nunca criar dois ícones. O ícone vive no processo, não na página: uma página recarregada tira o ícone `botloft` que ficou (`getById`/`removeById`) antes de criar o seu. No build de dev o texto do ícone termina em `(dev)`, para distinguir da cópia instalada | | avisos do Windows | `tauri-plugin-notification` (`notification:default`). Sem `sound`, o toast é mudo; com "Tocar um som", `sound: "Default"`. O app instalado usa o próprio AppUserModelID; em dev, o do PowerShell | | `claude_sign_in` | abre `claude auth login` numa janela de console própria (`CREATE_NEW_CONSOLE`) com o `claudePath` do `system.status` e espera; responde se saiu com 0. Só roda um caminho absoluto de arquivo existente chamado `claude.exe`. Tira do ambiente `CLAUDECODE` e `CLAUDE_CODE_*`, que um app aberto de dentro de uma sessão do Claude Code herdaria. Quem abre é o app, e não o daemon, porque a janela aberta pelo app em primeiro plano vem para a frente. Na janela o dono vê o que acontece e pode colar um código se o navegador pedir. No Linux e no macOS (`claude`, sem `.exe`) é uma janela de terminal: o Terminal pelo `osascript` no macOS; no Linux o primeiro de `x-terminal-emulator`, `gnome-terminal`, `konsole`, `xfce4-terminal`, `kitty`, `alacritty` e `xterm` no `PATH`. O terminal roda `sh -c` com o comando, que grava o código de saída num arquivo numa pasta temporária `0700`; o app espera esse arquivo por até 15 min, porque um terminal pode passar o comando a um processo que já roda e voltar na hora. Fechar o terminal sem entrar deixa o app esperando até esse limite | | `read_owner_token` | lê `secrets\owner.token` (só no app local) |