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
4 changes: 3 additions & 1 deletion app/src/lib/tauriHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>("claude_sign_in", { path }),
setZoom: (factor) => getCurrentWebview().setZoom(factor),
Expand Down
54 changes: 54 additions & 0 deletions app/src/lib/tauriTray.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
9 changes: 8 additions & 1 deletion app/src/lib/tauriTray.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> = Promise.resolve();
let tray: TrayIcon | null = null;
let menu: Menu | null = null;
Expand Down Expand Up @@ -62,8 +64,13 @@ async function apply(view: TrayView): Promise<void> {
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,
Expand Down
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `"<app>" --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) |
Expand Down
Loading