diff --git a/Cargo.lock b/Cargo.lock index 92df1372..68a297ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -333,6 +333,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.30.0", "toml 1.1.6+spec-1.1.0", + "toml_edit 0.25.15+spec-1.1.0", "tracing", "tracing-appender", "tracing-subscriber", @@ -4575,6 +4576,7 @@ dependencies = [ "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.4", ] diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 9eff8b62..c213247d 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -11,6 +11,8 @@ "core:window:allow-minimize", "core:window:allow-toggle-maximize", "core:window:allow-close", + "core:window:allow-hide", + "core:window:allow-destroy", "core:window:allow-set-overlay-icon", "core:webview:allow-set-webview-zoom", "updater:default", diff --git a/app/src-tauri/src/daemon.rs b/app/src-tauri/src/daemon.rs index 16dab5e0..e3edb1ba 100644 --- a/app/src-tauri/src/daemon.rs +++ b/app/src-tauri/src/daemon.rs @@ -159,6 +159,12 @@ pub fn restart(endpoint: &Endpoint) -> Result { Ok(status(endpoint)) } +/// Stops the daemon and keeps its scheduled task from bringing it back +/// before the app opens again (spec 14). +pub fn stop(endpoint: &Endpoint) -> Result<(), String> { + run_service(endpoint, "stop") +} + /// Runs `botloftd --home service ` without a window and /// returns its error message if it fails. fn run_service(endpoint: &Endpoint, command: &str) -> Result<(), String> { diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 97d5472a..df3f9dce 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -36,6 +36,14 @@ async fn daemon_restart() -> Result { blocking(|| daemon::restart(&daemon::endpoint()?)).await } +/// Stops the daemon until the app opens again, or until the next sign-in +/// when it starts with Windows: for an owner who wants the bots to stop +/// when they close Botloft. +#[tauri::command] +async fn daemon_stop() -> Result<(), String> { + blocking(|| daemon::stop(&daemon::endpoint()?)).await +} + /// Opens Claude Code's sign-in in its own window and waits for it to end. /// Returns whether it signed in. #[tauri::command] @@ -158,6 +166,7 @@ pub fn run() { daemon_status, daemon_install, daemon_restart, + daemon_stop, claude_sign_in, read_owner_token, open_path, diff --git a/app/src/App.tsx b/app/src/App.tsx index cf384c2f..dba635fe 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -3,6 +3,7 @@ import { useStore } from "zustand"; import { type Connect, createLink } from "./features/onboarding/link"; import { Onboarding } from "./features/onboarding/Onboarding"; import type { Host } from "./lib/host"; +import { useCloseBehavior } from "./shell/closing"; import { TitleBar } from "./shell/TitleBar"; import { useThemeRoot } from "./shell/theme"; import { Workspace } from "./shell/Workspace"; @@ -13,6 +14,7 @@ import { Toaster } from "./ui/toast"; export function App({ host, connect }: { host: Host; connect: Connect }) { useThemeRoot(); useZoomRoot(host); + useCloseBehavior(host); const [link] = useState(() => createLink(host, connect)); const current = useStore(link, (state) => state.current); useEffect(() => { diff --git a/app/src/features/account/AboutSettings.tsx b/app/src/features/account/AboutSettings.tsx new file mode 100644 index 00000000..fecccfa1 --- /dev/null +++ b/app/src/features/account/AboutSettings.tsx @@ -0,0 +1,70 @@ +// Settings, "About": the versions, for when something needs reporting, +// and a way to look for a new Botloft now instead of waiting for the +// title bar (spec 15.5). + +import { RefreshCw } from "lucide-react"; +import { useState } from "react"; +import { useT } from "../../i18n"; +import type { AppUpdate } from "../../lib/host"; +import { useApp, useHost } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { UpdateDialog } from "../updates/UpdateButton"; +import { Section } from "./settingsParts"; + +type Check = + | { stage: "idle" } + | { stage: "checking" } + | { stage: "latest" } + | { stage: "found"; update: AppUpdate } + | { stage: "failed" }; + +export function AboutSettings() { + const s = useT().account.settings; + const host = useHost(); + const system = useApp((state) => state.system); + const [check, setCheck] = useState({ stage: "idle" }); + const [open, setOpen] = useState(false); + + const look = () => { + setCheck({ stage: "checking" }); + host.checkForUpdate().then( + (update) => setCheck(update ? { stage: "found", update } : { stage: "latest" }), + () => setCheck({ stage: "failed" }), + ); + }; + + const said = { + idle: null, + checking: s.checking, + latest: s.upToDate, + found: check.stage === "found" ? s.updateFound(check.update.version) : null, + failed: s.checkFailed, + }[check.stage]; + + return ( +
+
    + {system &&
  • {s.botloft(system.daemonVersion)}
  • } + {system?.claudeVersion &&
  • {s.claudeCode(system.claudeVersion)}
  • } + {system?.account.claude?.email &&
  • {system.account.claude.email}
  • } +
+
+ {check.stage === "found" ? ( + + ) : ( + + )} +

+ {said} +

+
+ {open && check.stage === "found" && ( + setOpen(false)} /> + )} +
+ ); +} diff --git a/app/src/features/account/BackgroundSettings.tsx b/app/src/features/account/BackgroundSettings.tsx new file mode 100644 index 00000000..a422e603 --- /dev/null +++ b/app/src/features/account/BackgroundSettings.tsx @@ -0,0 +1,40 @@ +// Settings, "In the background" (spec 15.1): whether the bots keep working +// after the window closes, whether Botloft starts with Windows, and +// whether the computer stays awake while they work. + +import { useT } from "../../i18n"; +import { setWhenClosed, useWhenClosed } from "../../shell/closing"; +import { Section, Toggle } from "./settingsParts"; +import { useDaemonSettings } from "./useDaemonSettings"; + +export function BackgroundSettings() { + const s = useT().account.settings; + const whenClosed = useWhenClosed(); + const { settings, change } = useDaemonSettings(); + const startWithWindows = settings?.startWithWindows ?? true; + + return ( +
+ setWhenClosed(on ? "keep" : "stop")} + /> + change({ startWithWindows: on })} + /> + change({ keepAwake: on })} + /> +
+ ); +} diff --git a/app/src/features/account/SettingsDialog.tsx b/app/src/features/account/SettingsDialog.tsx index 5514d8ac..58e5366b 100644 --- a/app/src/features/account/SettingsDialog.tsx +++ b/app/src/features/account/SettingsDialog.tsx @@ -1,7 +1,7 @@ -// Settings (spec 15.1): theme, size and language, which used to sit in the -// title bar, and the versions for when something needs reporting. +// Settings (spec 15.1): whether Botloft works in the background, the +// theme, size and language, which used to sit in the title bar, and the +// versions for when something needs reporting. -import type { ReactNode } from "react"; import { LOCALES, type LocaleChoice, @@ -12,9 +12,11 @@ import { } from "../../i18n"; import { setTheme, type ThemeChoice, useTheme } from "../../shell/theme"; import { DEFAULT_ZOOM, setZoom, useZoom, ZOOM_LEVELS, type ZoomLevel } from "../../shell/zoom"; -import { useApp } from "../../store/context"; import { Choices } from "../../ui/Choices"; import { Dialog } from "../../ui/Dialog"; +import { AboutSettings } from "./AboutSettings"; +import { BackgroundSettings } from "./BackgroundSettings"; +import { Field, Section } from "./settingsParts"; export function SettingsDialog({ onClose }: { onClose(): void }) { const t = useT(); @@ -22,7 +24,6 @@ export function SettingsDialog({ onClose }: { onClose(): void }) { const { choice: theme } = useTheme(); const zoom = useZoom(); const { choice: locale } = useLocale(); - const system = useApp((state) => state.system); const systemName = LOCALES.find((entry) => entry.id === systemLocale())?.name ?? "English"; const themes: { value: ThemeChoice; label: string }[] = [ @@ -42,6 +43,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) { return (
+
@@ -58,33 +60,8 @@ export function SettingsDialog({ onClose }: { onClose(): void }) { onChange={setLocaleChoice} />
-
-
    - {system &&
  • {s.botloft(system.daemonVersion)}
  • } - {system?.claudeVersion &&
  • {s.claudeCode(system.claudeVersion)}
  • } - {system?.account.claude?.email &&
  • {system.account.claude.email}
  • } -
-
+
); } - -function Section({ title, children }: { title: string; children: ReactNode }) { - return ( -
-

{title}

- {children} -
- ); -} - -function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { - return ( -
-

{label}

- {children} - {hint &&

{hint}

} -
- ); -} diff --git a/app/src/features/account/settings.test.tsx b/app/src/features/account/settings.test.tsx new file mode 100644 index 00000000..cf4be40d --- /dev/null +++ b/app/src/features/account/settings.test.tsx @@ -0,0 +1,133 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { App } from "../../App"; +import type { Client } from "../../lib/client"; +import { FakeBotloft } from "../../lib/fake"; +import { FakeHost, FakeUpdate } from "../../lib/fakeHost"; +import { RpcError } from "../../lib/rpc"; +import { setWhenClosed } from "../../shell/closing"; + +afterEach(() => { + cleanup(); + setWhenClosed("keep"); +}); + +function renderApp(fake = new FakeBotloft(), host = new FakeHost()) { + render( fake as Client} />); + return { fake, host }; +} + +async function openSettings() { + fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "Settings" })); + return screen.getByRole("dialog", { name: "Settings" }); +} + +describe("settings", () => { + test("in the background: each switch says what it does and is saved", async () => { + const { fake } = renderApp(); + const dialog = await openSettings(); + const keep = within(dialog).getByRole("switch", { + name: "Keep working after you close Botloft", + }); + const start = within(dialog).getByRole("switch", { name: "Start with Windows" }); + const awake = within(dialog).getByRole("switch", { + name: "Keep the computer awake while bots work", + }); + await waitFor(() => expect(start.hasAttribute("disabled")).toBe(false)); + expect([keep, start, awake].map((s) => s.getAttribute("aria-checked"))).toEqual([ + "true", + "true", + "true", + ]); + expect(dialog.textContent).toContain("Your bots go on working and answering"); + + fireEvent.click(keep); + expect(keep.getAttribute("aria-checked")).toBe("false"); + expect(dialog.textContent).toContain("Closing Botloft stops every bot."); + expect(localStorage.getItem("botloft.whenClosed")).toBe("stop"); + + fireEvent.click(start); + expect(start.getAttribute("aria-checked")).toBe("false"); + expect(dialog.textContent).toContain("the bots wait until you open Botloft"); + fireEvent.click(awake); + await waitFor(() => + expect(fake.settings).toEqual({ startWithWindows: false, keepAwake: false }), + ); + expect( + fake.calls.filter((call) => call.method === "settings.update").map((c) => c.params), + ).toEqual([{ startWithWindows: false }, { keepAwake: false }]); + }); + + test("a setting that cannot be saved goes back", async () => { + const { fake } = renderApp(); + const dialog = await openSettings(); + const start = within(dialog).getByRole("switch", { name: "Start with Windows" }); + await waitFor(() => expect(start.hasAttribute("disabled")).toBe(false)); + fake.failNext("settings.update", new RpcError(-32603, "could not save the settings")); + fireEvent.click(start); + await screen.findByText(/Could not change the setting/); + expect(start.getAttribute("aria-checked")).toBe("true"); + expect(fake.settings.startWithWindows).toBe(true); + }); + + test("about looks for a new version on request", async () => { + const { host } = renderApp(); + const dialog = await openSettings(); + fireEvent.click(within(dialog).getByRole("button", { name: "Check for updates" })); + expect(await within(dialog).findByText("You have the latest version.")).toBeDefined(); + host.update = new FakeUpdate("9.9.9"); + fireEvent.click(within(dialog).getByRole("button", { name: "Check for updates" })); + expect(await within(dialog).findByText("Botloft 9.9.9 is out.")).toBeDefined(); + fireEvent.click(within(dialog).getByRole("button", { name: "See the update" })); + expect(screen.getByRole("dialog", { name: "Update Botloft" }).textContent).toContain("9.9.9"); + }); +}); + +describe("closing the window", () => { + test("leaves the bots working unless the owner chose otherwise", async () => { + const { host } = renderApp(); + await screen.findByRole("heading", { name: "Welcome to Botloft" }); + await act(() => host.requestClose()); + expect(host.closed).toBe(true); + expect(host.stops).toBe(0); + }); + + test("stops the bots, with the window out of sight first, when chosen", async () => { + const { host } = renderApp(); + await screen.findByRole("heading", { name: "Welcome to Botloft" }); + setWhenClosed("stop"); + await act(() => host.requestClose()); + expect(host.hidden).toBe(true); + expect(host.stops).toBe(1); + expect(host.closed).toBe(true); + }); + + test("opening Botloft again starts the bots it stopped", async () => { + setWhenClosed("stop"); + const host = new FakeHost(); + host.status = { state: "stopped", port: 45710, home: "C:\\data" }; + let finish: () => void = () => {}; + const installed = host.afterInstall; + host.installDaemon = () => + new Promise((resolve) => { + finish = () => resolve(installed as never); + }); + renderApp(new FakeBotloft(), host); + expect(await screen.findByText("Starting your bots…")).toBeDefined(); + expect(screen.queryByText(/In Settings you choose/)).toBeNull(); + await act(async () => finish()); + expect(await screen.findByText(/Running while this window is open/)).toBeDefined(); + }); + + test("the welcome screen says Botloft waits for the owner after a restart", async () => { + const fake = new FakeBotloft(); + fake.settings = { startWithWindows: false, keepAwake: true }; + renderApp(fake); + expect( + await screen.findByText( + "Running in the background, so your bots keep working after you close this window.", + ), + ).toBeDefined(); + }); +}); diff --git a/app/src/features/account/settingsParts.tsx b/app/src/features/account/settingsParts.tsx new file mode 100644 index 00000000..77beb74f --- /dev/null +++ b/app/src/features/account/settingsParts.tsx @@ -0,0 +1,70 @@ +// The pieces the Settings sections share: a titled section, a labelled +// field and an on/off row with its explanation. + +import { type ReactNode, useId } from "react"; +import { Switch } from "../../ui/Switch"; + +export function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: ReactNode; +}) { + return ( +
+

{label}

+ {children} + {hint &&

{hint}

} +
+ ); +} + +/** A setting that is on or off; the hint says what the current choice does. */ +export function Toggle({ + label, + hint, + checked, + disabled, + onChange, +}: { + label: string; + hint: string; + checked: boolean; + disabled?: boolean | undefined; + onChange(checked: boolean): void; +}) { + const id = useId(); + return ( +
+
+ +

+ {hint} +

+
+ +
+ ); +} diff --git a/app/src/features/account/useDaemonSettings.ts b/app/src/features/account/useDaemonSettings.ts new file mode 100644 index 00000000..4b10c454 --- /dev/null +++ b/app/src/features/account/useDaemonSettings.ts @@ -0,0 +1,44 @@ +// The daemon's part of Settings (spec 11.2): read when shown and saved on +// each change. A change shows at once and goes back if it cannot be saved. + +import { useEffect, useState } from "react"; +import { useT } from "../../i18n"; +import type { Settings, SettingsUpdateParams } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { attempt } from "../../ui/toast"; + +export function useDaemonSettings(): { + settings: Settings | null; + change(update: SettingsUpdateParams): void; +} { + const api = useApi(); + const failed = useT().account.settings.saveFailed; + const [settings, setSettings] = useState(null); + + useEffect(() => { + let alive = true; + api.call("settings.get").then( + (found) => alive && setSettings(found), + // Shown without them; the switches wait. + () => {}, + ); + return () => { + alive = false; + }; + }, [api]); + + const change = (update: SettingsUpdateParams) => { + const before = settings; + if (before) { + setSettings({ + startWithWindows: update.startWithWindows ?? before.startWithWindows, + keepAwake: update.keepAwake ?? before.keepAwake, + }); + } + attempt(failed, async () => setSettings(await api.call("settings.update", update))).then( + (saved) => saved || setSettings(before), + ); + }; + + return { settings, change }; +} diff --git a/app/src/features/onboarding/Welcome.tsx b/app/src/features/onboarding/Welcome.tsx index 5d9e803b..f2680bdc 100644 --- a/app/src/features/onboarding/Welcome.tsx +++ b/app/src/features/onboarding/Welcome.tsx @@ -1,8 +1,10 @@ import { CircleCheck, CircleX, LoaderCircle, Plus } from "lucide-react"; import { type ReactNode, useState } from "react"; import { useT } from "../../i18n"; +import { useWhenClosed } from "../../shell/closing"; import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; +import { useDaemonSettings } from "../account/useDaemonSettings"; import { BotAvatar } from "../bots/BotAvatar"; import { CrewDialog } from "../crews/CrewDialog"; import { ClaudeCodeHelp } from "./ClaudeCodeHelp"; @@ -13,6 +15,14 @@ export function Welcome() { const w = useT().onboarding.welcome; const system = useApp((state) => state.system); const [creating, setCreating] = useState(false); + const whenClosed = useWhenClosed(); + const { settings } = useDaemonSettings(); + const running = + whenClosed === "stop" + ? w.runningWhileOpen + : settings?.startWithWindows === false + ? w.runningNotAtStart + : w.running; let claude: ReactNode; if (!system || (system.claudeVersion === null && system.runtimeError === null)) { @@ -63,7 +73,7 @@ export function Welcome() {

{w.title}

{w.intro}

    - + {claude} {account}
diff --git a/app/src/features/onboarding/link.ts b/app/src/features/onboarding/link.ts index c940ef9f..eb4b2092 100644 --- a/app/src/features/onboarding/link.ts +++ b/app/src/features/onboarding/link.ts @@ -10,13 +10,17 @@ import { errorText } from "../../lib/api"; import type { Client } from "../../lib/client"; import type { DaemonStatus, Host } from "../../lib/host"; import { PROTOCOL_VERSION } from "../../lib/protocol.gen"; +import { whenClosed } from "../../shell/closing"; export type LinkStep = | { step: "checking" } /** Not running, and installing it did not help. */ | { step: "stopped"; port: number; home: string; error: string | null } - /** Installing, updating to this app's version, or restarting the daemon. */ - | { step: "installing"; action: "install" | "update" | "restart" } + /** + * Installing, updating to this app's version or restarting the daemon, or + * starting it again after the owner closed Botloft with the bots. + */ + | { step: "installing"; action: "install" | "update" | "restart" | "start" } /** Another program holds the daemon's port. */ | { step: "foreign"; port: number } /** The daemon speaks another protocol version and is not older than the app. */ @@ -95,7 +99,9 @@ export function createLink(host: Host, connect: Connect): StoreApi { switch (status.state) { case "stopped": if (mayChange) { - await change(run, "install", () => host.installDaemon()); + // Closing Botloft stopped the bots: opening it brings them back. + const action = whenClosed() === "stop" ? "start" : "install"; + await change(run, action, () => host.installDaemon()); return; } set({ current: { step: "stopped", port: status.port, home: status.home, error } }); @@ -135,7 +141,7 @@ export function createLink(host: Host, connect: Connect): StoreApi { /** Installs, updates or restarts, then settles on what runs afterwards. */ const change = async ( run: number, - action: "install" | "update" | "restart", + action: "install" | "update" | "restart" | "start", work: () => Promise, ) => { set({ current: { step: "installing", action } }); diff --git a/app/src/features/routines/RoutineRow.tsx b/app/src/features/routines/RoutineRow.tsx index b58b5c9a..c461baf3 100644 --- a/app/src/features/routines/RoutineRow.tsx +++ b/app/src/features/routines/RoutineRow.tsx @@ -18,6 +18,7 @@ import { useApi, useApp } from "../../store/context"; import { Button } from "../../ui/Button"; import { Confirm } from "../../ui/Confirm"; import { Menu } from "../../ui/Menu"; +import { Switch } from "../../ui/Switch"; import { attempt } from "../../ui/toast"; import { BotAvatar } from "../bots/BotAvatar"; import { describeSchedule, nextText } from "./describe"; @@ -52,22 +53,12 @@ export function RoutineRow({ return (
  • - +
    {showBot && (

    diff --git a/app/src/features/updates/UpdateButton.tsx b/app/src/features/updates/UpdateButton.tsx index cea64fc5..01cb8af6 100644 --- a/app/src/features/updates/UpdateButton.tsx +++ b/app/src/features/updates/UpdateButton.tsx @@ -65,7 +65,7 @@ type Progress = | { stage: "downloading"; fraction: number | null } | { stage: "failed"; error: string }; -function UpdateDialog({ update, onClose }: { update: AppUpdate; onClose(): void }) { +export function UpdateDialog({ update, onClose }: { update: AppUpdate; onClose(): void }) { const t = useT(); const u = t.updates; const [progress, setProgress] = useState({ stage: "idle" }); diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index da35e870..f4210bf7 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -30,6 +30,18 @@ export const account = { }, settings: { title: "Settings", + background: "In the background", + keepWorking: "Keep working after you close Botloft", + keepWorkingOn: "Your bots go on working and answering after you close this window.", + keepWorkingOff: + "Closing Botloft stops every bot. They pick up where they left off when you open it again.", + startWithWindows: "Start with Windows", + startWithWindowsOn: + "When you sign in to Windows, your bots get back to work on their own, without opening this window.", + startWithWindowsOff: "After you restart the computer, the bots wait until you open Botloft.", + keepAwake: "Keep the computer awake while bots work", + keepAwakeHint: "It still sleeps when you close the lid or choose Sleep.", + saveFailed: "Could not change the setting", appearance: "Appearance", theme: "Theme", themes: { system: "System", light: "Light", dark: "Dark" }, @@ -39,5 +51,11 @@ export const account = { about: "About", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, + checkUpdates: "Check for updates", + checking: "Checking…", + upToDate: "You have the latest version.", + updateFound: (version: string) => `Botloft ${version} is out.`, + seeUpdate: "See the update", + checkFailed: "Could not check for updates. Try again later.", }, }; diff --git a/app/src/i18n/en/onboarding.ts b/app/src/i18n/en/onboarding.ts index 05fe3a18..1fdbc67d 100644 --- a/app/src/i18n/en/onboarding.ts +++ b/app/src/i18n/en/onboarding.ts @@ -10,9 +10,10 @@ export const onboarding = { install: "Getting Botloft ready…", update: "Updating Botloft…", restart: "Restarting Botloft…", + start: "Starting your bots…", }, installNote: - "Botloft keeps your bots running in the background, even after you close this window, and starts with Windows.", + "Botloft runs your bots in the background. In Settings you choose whether they keep working after you close this window and whether Botloft starts with Windows.", stopped: { title: "Botloft couldn't start", body: "Botloft runs your bots in the background, and that part didn't start.", @@ -49,6 +50,9 @@ export const onboarding = { botloft: "Botloft", running: "Running in the background. It starts with Windows, so your bots keep working after you close this window.", + runningNotAtStart: + "Running in the background, so your bots keep working after you close this window.", + runningWhileOpen: "Running while this window is open. Closing it stops your bots.", claudeCode: "Claude Code", checking: "Checking…", version: (version: string) => `Version ${version}`, diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index 8a18cf30..61c05369 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -30,6 +30,18 @@ export const account: Messages["account"] = { }, settings: { title: "Configuración", + background: "En segundo plano", + keepWorking: "Seguir trabajando después de cerrar Botloft", + keepWorkingOn: "Tus bots siguen trabajando y respondiendo después de que cierras esta ventana.", + keepWorkingOff: + "Al cerrar Botloft, todos los bots se detienen. Siguen donde se quedaron cuando lo vuelves a abrir.", + startWithWindows: "Iniciar con Windows", + startWithWindowsOn: + "Cuando inicias sesión en Windows, tus bots vuelven a trabajar solos, sin abrir esta ventana.", + startWithWindowsOff: "Después de reiniciar el equipo, los bots esperan a que abras Botloft.", + keepAwake: "Mantener el equipo despierto mientras los bots trabajan", + keepAwakeHint: "Igual se suspende si cierras la tapa o eliges Suspender.", + saveFailed: "No se pudo cambiar el ajuste", appearance: "Apariencia", theme: "Tema", themes: { system: "Sistema", light: "Claro", dark: "Oscuro" }, @@ -39,5 +51,11 @@ export const account: Messages["account"] = { about: "Acerca de", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, + checkUpdates: "Buscar actualizaciones", + checking: "Buscando…", + upToDate: "Tienes la versión más reciente.", + updateFound: (version: string) => `Salió Botloft ${version}.`, + seeUpdate: "Ver la actualización", + checkFailed: "No se pudieron buscar actualizaciones. Inténtalo más tarde.", }, }; diff --git a/app/src/i18n/es/onboarding.ts b/app/src/i18n/es/onboarding.ts index f25217f7..9db876f2 100644 --- a/app/src/i18n/es/onboarding.ts +++ b/app/src/i18n/es/onboarding.ts @@ -9,9 +9,10 @@ export const onboarding: Messages["onboarding"] = { install: "Preparando Botloft…", update: "Actualizando Botloft…", restart: "Reiniciando Botloft…", + start: "Iniciando tus bots…", }, installNote: - "Botloft mantiene tus bots funcionando en segundo plano, incluso después de cerrar esta ventana, y se inicia con Windows.", + "Botloft ejecuta tus bots en segundo plano. En Configuración eliges si siguen trabajando después de cerrar esta ventana y si Botloft se inicia con Windows.", stopped: { title: "Botloft no pudo iniciar", body: "Botloft ejecuta tus bots en segundo plano, y esa parte no se inició.", @@ -48,6 +49,10 @@ export const onboarding: Messages["onboarding"] = { botloft: "Botloft", running: "Funcionando en segundo plano. Se inicia con Windows, así que tus bots siguen trabajando después de cerrar esta ventana.", + runningNotAtStart: + "Funcionando en segundo plano, así que tus bots siguen trabajando después de cerrar esta ventana.", + runningWhileOpen: + "Funcionando mientras esta ventana está abierta. Al cerrarla, tus bots se detienen.", claudeCode: "Claude Code", checking: "Comprobando…", version: (version: string) => `Versión ${version}`, diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index aaa52320..b371a93a 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -30,6 +30,19 @@ export const account: Messages["account"] = { }, settings: { title: "Configurações", + background: "Em segundo plano", + keepWorking: "Continuar trabalhando depois de fechar o Botloft", + keepWorkingOn: "Seus bots seguem trabalhando e respondendo depois que você fecha esta janela.", + keepWorkingOff: + "Ao fechar o Botloft, todos os bots param. Eles continuam de onde pararam quando você abrir de novo.", + startWithWindows: "Iniciar com o Windows", + startWithWindowsOn: + "Quando você entra no Windows, seus bots voltam a trabalhar sozinhos, sem abrir esta janela.", + startWithWindowsOff: + "Depois que você reinicia o computador, os bots esperam você abrir o Botloft.", + keepAwake: "Não deixar o computador dormir enquanto os bots trabalham", + keepAwakeHint: "Ele ainda dorme quando você fecha a tampa ou escolhe Suspender.", + saveFailed: "Não foi possível mudar a configuração", appearance: "Aparência", theme: "Tema", themes: { system: "Sistema", light: "Claro", dark: "Escuro" }, @@ -39,5 +52,11 @@ export const account: Messages["account"] = { about: "Sobre", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, + checkUpdates: "Procurar atualizações", + checking: "Procurando…", + upToDate: "Você está na versão mais recente.", + updateFound: (version: string) => `O Botloft ${version} saiu.`, + seeUpdate: "Ver atualização", + checkFailed: "Não foi possível procurar atualizações. Tente mais tarde.", }, }; diff --git a/app/src/i18n/pt-BR/onboarding.ts b/app/src/i18n/pt-BR/onboarding.ts index 1e699416..aac28c04 100644 --- a/app/src/i18n/pt-BR/onboarding.ts +++ b/app/src/i18n/pt-BR/onboarding.ts @@ -9,9 +9,10 @@ export const onboarding: Messages["onboarding"] = { install: "Preparando o Botloft…", update: "Atualizando o Botloft…", restart: "Reiniciando o Botloft…", + start: "Iniciando seus bots…", }, installNote: - "O Botloft mantém seus bots rodando em segundo plano, mesmo depois que você fecha esta janela, e inicia com o Windows.", + "O Botloft roda seus bots em segundo plano. Em Configurações você escolhe se eles continuam trabalhando depois que você fecha esta janela e se o Botloft inicia com o Windows.", stopped: { title: "O Botloft não conseguiu iniciar", body: "O Botloft roda seus bots em segundo plano, e essa parte não iniciou.", @@ -48,6 +49,9 @@ export const onboarding: Messages["onboarding"] = { botloft: "Botloft", running: "Rodando em segundo plano. Inicia com o Windows, então seus bots continuam trabalhando depois que você fecha esta janela.", + runningNotAtStart: + "Rodando em segundo plano, então seus bots continuam trabalhando depois que você fecha esta janela.", + runningWhileOpen: "Rodando enquanto esta janela está aberta. Ao fechá-la, seus bots param.", claudeCode: "Claude Code", checking: "Verificando…", version: (version: string) => `Versão ${version}`, diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 1f4171cc..46360cba 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -19,6 +19,7 @@ import { type Crew, type CrewId, PROTOCOL_VERSION, + type Settings, type SystemStatus, } from "./protocol.gen"; import type { @@ -56,6 +57,7 @@ export class FakeBotloft implements BotloftApi { }; /** How many times the app asked for a new Claude Code check. */ refreshes = 0; + settings: Settings = { startWithWindows: true, keepAwake: true }; /** Every call, in order. */ readonly calls: { method: Method; params: unknown }[] = []; readonly chat = new FakeChat(this); @@ -210,6 +212,14 @@ export class FakeBotloft implements BotloftApi { this.refreshes += 1; return this.system; }, + "settings.get": () => this.settings, + "settings.update": (change) => { + this.settings = { + startWithWindows: change.startWithWindows ?? this.settings.startWithWindows, + keepAwake: change.keepAwake ?? this.settings.keepAwake, + }; + return this.settings; + }, ...crewHandlers(this), ...botHandlers(this), ...this.chat.handlers(), diff --git a/app/src/lib/fakeHost.ts b/app/src/lib/fakeHost.ts index 0312ea65..6139d399 100644 --- a/app/src/lib/fakeHost.ts +++ b/app/src/lib/fakeHost.ts @@ -31,6 +31,11 @@ export class FakeHost implements Host { /** Where each folder picker started. */ readonly pickerStarts: (string | null)[] = []; readonly installs: ("install" | "restart")[] = []; + /** How many times the app stopped the daemon. */ + stops = 0; + /** What runs before the window closes, as the app set it. */ + beforeClose: (() => Promise) | null = null; + hidden = false; /** Paths `signInToClaude` ran, and what it answers. */ readonly signIns: string[] = []; signInResult: boolean | Error = true; @@ -57,6 +62,17 @@ export class FakeHost implements Host { return this.settle("restart"); } + stopDaemon(): Promise { + this.stops += 1; + return Promise.resolve(); + } + + /** Closes the window the way Windows does: what the app set runs first. */ + async requestClose(): Promise { + await this.beforeClose?.(); + this.closed = true; + } + signInToClaude(claudePath: string): Promise { this.signIns.push(claudePath); if (this.signInResult instanceof Error) { @@ -122,10 +138,19 @@ export class FakeHost implements Host { this.maximized = !this.maximized; return Promise.resolve(); }, - close: () => { - this.closed = true; + close: () => this.requestClose(), + hide: () => { + this.hidden = true; return Promise.resolve(); }, + onCloseRequested: (before: () => Promise) => { + this.beforeClose = before; + return Promise.resolve(() => { + if (this.beforeClose === before) { + this.beforeClose = null; + } + }); + }, isMaximized: () => Promise.resolve(this.maximized), onResized: () => Promise.resolve(() => {}), setAttention: (on: boolean) => { diff --git a/app/src/lib/host.ts b/app/src/lib/host.ts index de7da2e5..aa092757 100644 --- a/app/src/lib/host.ts +++ b/app/src/lib/host.ts @@ -35,6 +35,13 @@ export interface AppWindow { minimize(): Promise; toggleMaximize(): Promise; close(): Promise; + hide(): Promise; + /** + * Runs `before` when the window is about to close (the close button, + * Alt+F4, the taskbar), and closes it once it settles. Returns the + * unsubscribe function. + */ + onCloseRequested(before: () => Promise): Promise<() => void>; isMaximized(): Promise; /** Called when the window is resized; returns the unsubscribe function. */ onResized(listener: () => void): Promise<() => void>; @@ -52,6 +59,11 @@ export interface Host { installDaemon(): Promise; /** Stops the daemon and starts it again from its scheduled task. */ restartDaemon(): Promise; + /** + * Stops the daemon until the app opens again, or until the next sign-in + * when it starts with Windows (spec 14). + */ + stopDaemon(): Promise; /** * Opens Claude Code's sign-in (`claude auth login`) in its own window and * waits for it to end. Resolves whether it signed in. diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 9f64e58d..c6b00730 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -704,6 +704,25 @@ note?: string, */ input?: string, }; +/** + * The daemon's part of the app's Settings. + */ +export type Settings = { +/** + * Botloft starts when the owner signs in to Windows (spec 14). Off, + * the bots wait until the owner opens the app. + */ +startWithWindows: boolean, +/** + * The computer does not sleep while a bot works (spec 14). + */ +keepAwake: boolean, }; + +/** + * The settings to change; the ones left out stay as they are. + */ +export type SettingsUpdateParams = { startWithWindows?: boolean, keepAwake?: boolean, }; + /** * When a routine runs (spec 20.2), kept as structure rather than cron text * so the app can show and edit it without jargon. @@ -950,6 +969,8 @@ export interface RpcMethods { "session.hello": { params: HelloParams; result: HelloResult }; "system.status": { params: undefined; result: SystemStatus }; "system.refresh": { params: undefined; result: SystemStatus }; + "settings.get": { params: undefined; result: Settings }; + "settings.update": { params: SettingsUpdateParams; result: Settings }; "crews.list": { params: undefined; result: Array }; "crews.create": { params: CrewsCreateParams; result: Crew }; "crews.rename": { params: CrewsRenameParams; result: Crew }; diff --git a/app/src/lib/tauriHost.ts b/app/src/lib/tauriHost.ts index e563ca3b..2261c403 100644 --- a/app/src/lib/tauriHost.ts +++ b/app/src/lib/tauriHost.ts @@ -71,6 +71,7 @@ export function tauriHost(): Host { daemonStatus: () => invoke("daemon_status"), installDaemon: () => invoke("daemon_install"), restartDaemon: () => invoke("daemon_restart"), + stopDaemon: () => invoke("daemon_stop"), signInToClaude: (path) => invoke("claude_sign_in", { path }), setZoom: (factor) => getCurrentWebview().setZoom(factor), checkForUpdate, @@ -92,6 +93,8 @@ export function tauriHost(): Host { minimize: () => window.minimize(), toggleMaximize: () => window.toggleMaximize(), close: () => window.close(), + hide: () => window.hide(), + onCloseRequested: (before) => window.onCloseRequested(() => before()), isMaximized: () => window.isMaximized(), onResized: (listener) => window.onResized(() => listener()), setAttention: async (on) => { diff --git a/app/src/shell/closing.ts b/app/src/shell/closing.ts new file mode 100644 index 00000000..82638b77 --- /dev/null +++ b/app/src/shell/closing.ts @@ -0,0 +1,82 @@ +// What closing the window does (spec 15.1): Botloft keeps the bots working +// in the background, or, if the owner chose so in Settings, stops them +// until it opens again. The choice is this app's, kept in localStorage +// like the theme. + +import { useEffect, useSyncExternalStore } from "react"; +import type { Host } from "../lib/host"; + +export type WhenClosed = "keep" | "stop"; + +const KEY = "botloft.whenClosed"; +const listeners = new Set<() => void>(); + +function stored(): WhenClosed { + try { + return localStorage.getItem(KEY) === "stop" ? "stop" : "keep"; + } catch { + return "keep"; + } +} + +let choice: WhenClosed = stored(); + +/** The owner's choice, for code outside React. */ +export function whenClosed(): WhenClosed { + return choice; +} + +export function setWhenClosed(next: WhenClosed): void { + choice = next; + try { + localStorage.setItem(KEY, next); + } catch { + // Private storage off: the choice lasts for this window. + } + for (const listener of listeners) { + listener(); + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useWhenClosed(): WhenClosed { + return useSyncExternalStore(subscribe, () => choice); +} + +/** + * Stops the bots as the window closes, when the owner chose so. The window + * hides first, since stopping takes a moment; if it fails, it closes all + * the same. + */ +export function useCloseBehavior(host: Host): void { + useEffect(() => { + let off: (() => void) | undefined; + let alive = true; + host.window + .onCloseRequested(async () => { + if (choice !== "stop") { + return; + } + await host.window.hide().catch(() => {}); + await host.stopDaemon().catch(() => {}); + }) + .then( + (unsubscribe) => { + if (alive) { + off = unsubscribe; + } else { + unsubscribe(); + } + }, + () => {}, + ); + return () => { + alive = false; + off?.(); + }; + }, [host]); +} diff --git a/app/src/ui/Switch.tsx b/app/src/ui/Switch.tsx new file mode 100644 index 00000000..830f41fd --- /dev/null +++ b/app/src/ui/Switch.tsx @@ -0,0 +1,46 @@ +/** + * An on/off switch: a button with `role="switch"`, so screen readers say + * whether it is on. Name it with `label` or `labelledBy`. + */ +export function Switch({ + checked, + onChange, + id, + label, + labelledBy, + describedBy, + disabled, + className = "", +}: { + checked: boolean; + onChange(checked: boolean): void; + id?: string; + label?: string; + labelledBy?: string; + describedBy?: string; + disabled?: boolean | undefined; + className?: string; +}) { + return ( + + ); +} diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index f10569a4..7e5617a0 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -162,6 +162,8 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); + out.decl::(); routines::decls(&mut out); browser::decls(&mut out); @@ -188,6 +190,12 @@ fn export_bindings() { "undefined", &out.name::(), ); + out.method(method::SETTINGS_GET, "undefined", &out.name::()); + out.method( + method::SETTINGS_UPDATE, + &out.name::(), + &out.name::(), + ); out.method(method::CREWS_LIST, "undefined", &crews); out.method( method::CREWS_CREATE, diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 0fbd4d70..43054c83 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -60,6 +60,7 @@ mod methods; mod model; mod routines; mod screens; +mod settings; pub use browser::*; pub use browser_input::*; @@ -70,6 +71,7 @@ pub use methods::*; pub use model::*; pub use routines::*; pub use screens::*; +pub use settings::*; /// Version negotiated in `session.hello`. 2 replaced the terminal with the /// chat (ADR 0001). @@ -119,6 +121,8 @@ pub mod method { pub const BROWSER_RELEASE: &str = "browser.release"; pub const BROWSER_INPUT: &str = "browser.input"; pub const SCREENS_LIST: &str = "screens.list"; + pub const SETTINGS_GET: &str = "settings.get"; + pub const SETTINGS_UPDATE: &str = "settings.update"; } /// Server notification method names. diff --git a/crates/botloft-core/src/protocol/settings.rs b/crates/botloft-core/src/protocol/settings.rs new file mode 100644 index 00000000..6ac150b5 --- /dev/null +++ b/crates/botloft-core/src/protocol/settings.rs @@ -0,0 +1,29 @@ +//! What the owner changes in the app's Settings and the daemon keeps in +//! `config.toml` (spec 6, 11.2). + +use serde::{Deserialize, Serialize}; + +/// The daemon's part of the app's Settings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct Settings { + /// Botloft starts when the owner signs in to Windows (spec 14). Off, + /// the bots wait until the owner opens the app. + pub start_with_windows: bool, + /// The computer does not sleep while a bot works (spec 14). + pub keep_awake: bool, +} + +/// The settings to change; the ones left out stay as they are. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct SettingsUpdateParams { + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub start_with_windows: Option, + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub keep_awake: Option, +} diff --git a/crates/botloftd/Cargo.toml b/crates/botloftd/Cargo.toml index 08ad91bc..ad01857b 100644 --- a/crates/botloftd/Cargo.toml +++ b/crates/botloftd/Cargo.toml @@ -27,6 +27,7 @@ thiserror = "2.0.21" tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "signal", "time"] } tokio-tungstenite = "0.30.0" toml = "1.1.6" +toml_edit = "0.25.15" tracing = "0.1.44" tracing-appender = "0.2.5" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } @@ -35,7 +36,7 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } workspace = true [target."cfg(windows)".dependencies] -windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler", "Win32_Security_Authentication_Identity"] } +windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler", "Win32_System_RemoteDesktop", "Win32_Security_Authentication_Identity"] } [target."cfg(windows)".build-dependencies] embed-resource = "3.0.11" diff --git a/crates/botloftd/src/autostart/choice.rs b/crates/botloftd/src/autostart/choice.rs new file mode 100644 index 00000000..bba95525 --- /dev/null +++ b/crates/botloftd/src/autostart/choice.rs @@ -0,0 +1,150 @@ +//! The owner's choices about when Botloft runs (spec 14): starting with +//! Windows, and stopping it when they close the app. +//! +//! The task's triggers follow them: +//! - running: the minute trigger that brings a stopped daemon back, plus +//! the sign-in trigger when Botloft starts with Windows; +//! - stopped by the owner: only the sign-in trigger, when Botloft starts +//! with Windows, so nothing brings it back before then. +//! +//! The minute trigger also fires after a new sign-in. When Botloft does +//! not start with Windows, the daemon it starts sees that it is a new +//! sign-in (`\signin` names the last one Botloft ran in), takes the +//! triggers off and exits. The app starts it again when the owner opens it. + +use std::path::Path; + +use anyhow::{Context, bail}; +use tracing::{info, warn}; + +use super::{config, register, stop_task, task_name}; +use crate::platform::{self, Triggers}; + +const SIGN_IN_FILE: &str = "signin"; + +/// The triggers while the daemon should run. +pub(super) fn running(start_with_windows: bool) -> Triggers { + Triggers { + logon: start_with_windows, + watchdog: true, + } +} + +/// The triggers after the owner stopped it. +fn stopped(start_with_windows: bool) -> Triggers { + Triggers { + logon: start_with_windows, + watchdog: false, + } +} + +/// Records that Botloft runs in the owner's current sign-in. +pub(super) fn mark_sign_in(home: &Path) { + let Some(id) = platform::sign_in_id() else { + return; + }; + if let Err(err) = std::fs::write(home.join(SIGN_IN_FILE), id) { + warn!("cannot record the sign-in Botloft runs in: {err}"); + } +} + +/// Whether Botloft has not run in this sign-in yet. Unknown sign-ins +/// count as the same one, so the daemon keeps running. +fn is_new_sign_in(home: &Path) -> bool { + let Some(id) = platform::sign_in_id() else { + return false; + }; + std::fs::read_to_string(home.join(SIGN_IN_FILE)).map_or(true, |last| last.trim() != id) +} + +/// What the daemon started by its task does first. Returns whether it +/// should run: not after a new sign-in when Botloft does not start with +/// Windows. +pub fn scheduled_start(home: &Path, start_with_windows: bool) -> bool { + if !start_with_windows && is_new_sign_in(home) { + if let Err(err) = register(home, stopped(false)) { + warn!("cannot take the triggers off the scheduled task: {err:#}"); + } + return false; + } + mark_sign_in(home); + // Started at sign-in after the owner stopped it: the minute trigger + // comes back. + let wanted = running(start_with_windows); + let task = platform::find_task(&task_name(home)); + if task.is_ok_and(|task| task.is_some_and(|task| task.triggers != wanted)) + && let Err(err) = register(home, wanted) + { + warn!("cannot update the scheduled task: {err:#}"); + } + true +} + +/// `service stop`: stops the daemon until the owner opens Botloft again, +/// or until the next sign-in when it starts with Windows. +pub fn stop(home: &Path) -> anyhow::Result<()> { + let name = task_name(home); + if platform::find_task(&name)?.is_none() { + bail!("the scheduled task {name} is not installed; run `botloftd service install`"); + } + let config = config(home)?; + register(home, stopped(config.start_with_windows))?; + stop_task(&name, config.port)?; + println!("Stopped the daemon of the scheduled task {name}."); + Ok(()) +} + +/// Follows the owner turning starting with Windows on or off while the +/// daemon runs. A task that is not installed has nothing to follow. +pub fn set_start_with_windows(home: &Path, start: bool) -> anyhow::Result<()> { + let name = task_name(home); + let task = platform::find_task(&name) + .with_context(|| format!("cannot read the scheduled task {name}"))?; + if let Some(task) = task { + register( + home, + Triggers { + logon: start, + watchdog: task.triggers.watchdog, + }, + )?; + } + // The daemon runs in this sign-in, whatever started it. + mark_sign_in(home); + info!(start, "start with Windows"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_sign_in_is_new_until_botloft_runs_in_it() { + let home = tempfile::tempdir().expect("home"); + let Some(id) = platform::sign_in_id() else { + // Without a sign-in to name, the daemon always runs. + assert!(!is_new_sign_in(home.path())); + return; + }; + assert!(is_new_sign_in(home.path()), "nothing recorded yet"); + std::fs::write(home.path().join(SIGN_IN_FILE), "1-1").expect("write"); + assert!(is_new_sign_in(home.path()), "another sign-in"); + mark_sign_in(home.path()); + assert_eq!( + std::fs::read_to_string(home.path().join(SIGN_IN_FILE)).expect("read"), + id + ); + assert!(!is_new_sign_in(home.path())); + } + + #[test] + fn running_keeps_the_minute_trigger_and_stopping_takes_it_off() { + for start in [true, false] { + assert_eq!(running(start).logon, start); + assert_eq!(stopped(start).logon, start); + assert!(running(start).watchdog); + assert!(!stopped(start).watchdog); + } + } +} diff --git a/crates/botloftd/src/autostart/mod.rs b/crates/botloftd/src/autostart/mod.rs index 3b92c2ad..21075e4b 100644 --- a/crates/botloftd/src/autostart/mod.rs +++ b/crates/botloftd/src/autostart/mod.rs @@ -4,8 +4,11 @@ //! owner's Claude Code sign-in. pub mod binary; +mod choice; pub mod health; +pub use choice::{scheduled_start, set_start_with_windows, stop}; + use std::path::{Path, PathBuf}; use std::time::Duration; @@ -14,7 +17,7 @@ use sha2::{Digest, Sha256}; use crate::config::Config; use crate::paths; -use crate::platform::{self, TaskDefinition, TaskState}; +use crate::platform::{self, TaskDefinition, TaskState, Triggers}; /// How long a daemon may take to stop or to answer after starting. const STOP_TIMEOUT: Duration = Duration::from_secs(10); @@ -42,20 +45,34 @@ fn quote(arg: &str) -> String { format!("\"{arg}{}\"", "\\".repeat(trailing)) } -fn definition(home: &Path, program: &Path) -> TaskDefinition { +/// `--scheduled` tells the daemon the task started it (`choice`). +fn definition(home: &Path, program: &Path, triggers: Triggers) -> TaskDefinition { let home_text = home.to_string_lossy(); TaskDefinition { description: format!( "Starts the Botloft daemon at logon and keeps it running. Data folder: {home_text}" ), program: program.to_owned(), - arguments: format!("serve --home {}", quote(&home_text)), + arguments: format!("serve --home {} --scheduled", quote(&home_text)), working_dir: home.to_owned(), + triggers, } } +/// Registers the task with `triggers`, running the installed daemon. +fn register(home: &Path, triggers: Triggers) -> anyhow::Result<()> { + let name = task_name(home); + let program = bin_dir(home).join(binary::EXE_NAME); + platform::register_task(&name, &definition(home, &program, triggers)) + .with_context(|| format!("cannot register the scheduled task {name}")) +} + +fn config(home: &Path) -> anyhow::Result { + Ok(Config::load(&home.join("config.toml"), false)?) +} + fn port(home: &Path) -> anyhow::Result { - Ok(Config::load(&home.join("config.toml"), false)?.port) + Ok(config(home)?.port) } fn bin_dir(home: &Path) -> PathBuf { @@ -67,13 +84,15 @@ fn bin_dir(home: &Path) -> PathBuf { /// binary is left alone, so its bots are not interrupted. pub fn install(home: &Path) -> anyhow::Result<()> { std::fs::create_dir_all(home).with_context(|| format!("cannot create {}", home.display()))?; - let port = port(home)?; + let config = config(home)?; + let port = config.port; let exe = std::env::current_exe().context("cannot find this program")?; let installed = binary::install(&exe, &bin_dir(home)) .with_context(|| format!("cannot copy the daemon to {}", bin_dir(home).display()))?; let name = task_name(home); - platform::register_task(&name, &definition(home, &installed.path)) - .with_context(|| format!("cannot register the scheduled task {name}"))?; + register(home, choice::running(config.start_with_windows))?; + // The owner opened Botloft: it runs in this sign-in. + choice::mark_sign_in(home); println!( "Scheduled task {name} starts {} at logon.", installed.path.display() @@ -85,7 +104,7 @@ pub fn install(home: &Path) -> anyhow::Result<()> { println!("The daemon {VERSION} is already running on 127.0.0.1:{port}."); return Ok(()); } - restart_task(&name, port) + restart_task(home, port) } /// Stops the daemon and starts it again from the task. @@ -94,11 +113,13 @@ pub fn restart(home: &Path) -> anyhow::Result<()> { if platform::find_task(&name)?.is_none() { bail!("the scheduled task {name} is not installed; run `botloftd service install`"); } - restart_task(&name, port(home)?) + restart_task(home, port(home)?) } -fn restart_task(name: &str, port: u16) -> anyhow::Result<()> { +fn restart_task(home: &Path, port: u16) -> anyhow::Result<()> { + let name = &task_name(home); stop_task(name, port)?; + choice::mark_sign_in(home); platform::run_task(name).with_context(|| format!("cannot start the scheduled task {name}"))?; let up = health::wait(port, START_TIMEOUT, |health| { health.is_some_and(|health| health.version == VERSION) @@ -206,8 +227,9 @@ mod tests { let task = definition( Path::new(r"C:\data"), Path::new(r"C:\data\bin\botloftd.exe"), + choice::running(true), ); - assert_eq!(task.arguments, r#"serve --home "C:\data""#); + assert_eq!(task.arguments, r#"serve --home "C:\data" --scheduled"#); assert!(task.description.ends_with(r"Data folder: C:\data")); } diff --git a/crates/botloftd/src/config.rs b/crates/botloftd/src/config.rs index b6729008..b8085cd1 100644 --- a/crates/botloftd/src/config.rs +++ b/crates/botloftd/src/config.rs @@ -14,6 +14,9 @@ pub struct Config { pub workspaces_root: String, /// Empty means resolve `claude` from PATH. pub claude_path: String, + /// Start when the owner signs in to Windows (spec 14). + pub start_with_windows: bool, + /// Keep the computer awake while a bot works (spec 14). pub keep_awake: bool, pub log_level: String, pub supervisor: SupervisorConfig, @@ -29,6 +32,7 @@ impl Default for Config { port: 45710, workspaces_root: String::new(), claude_path: String::new(), + start_with_windows: true, keep_awake: true, log_level: "info".to_owned(), supervisor: SupervisorConfig::default(), diff --git a/crates/botloftd/src/keep_awake.rs b/crates/botloftd/src/keep_awake.rs index 3084a468..c0ac3879 100644 --- a/crates/botloftd/src/keep_awake.rs +++ b/crates/botloftd/src/keep_awake.rs @@ -1,13 +1,15 @@ //! Keeps the computer awake while any bot is `busy`, when `keep_awake` is -//! on (spec 14). A bot waiting for the owner or idle lets it sleep. +//! on (spec 14). A bot waiting for the owner or idle lets it sleep, and so +//! does the owner turning it off in Settings. use tokio::sync::watch; use tracing::{debug, warn}; use crate::platform::KeepAwake; -/// Follows the supervisor's count of busy bots until the daemon stops. -pub async fn run(busy: watch::Receiver) { +/// Follows the supervisor's count of busy bots and the owner's setting +/// until the daemon stops. +pub async fn run(busy: watch::Receiver, enabled: watch::Receiver) { let mut request = match KeepAwake::new() { Ok(request) => request, Err(err) => { @@ -15,7 +17,7 @@ pub async fn run(busy: watch::Receiver) { return; } }; - follow(busy, |on| { + follow(busy, enabled, |on| { debug!(on, "keep awake"); if let Err(err) = request.set(on) { warn!("cannot change the keep-awake request: {err}"); @@ -25,16 +27,24 @@ pub async fn run(busy: watch::Receiver) { } /// Calls `hold` with `true` when the first bot starts working and with -/// `false` when the last one stops. -async fn follow(mut busy: watch::Receiver, mut hold: impl FnMut(bool)) { +/// `false` when the last one stops, while `enabled`. +async fn follow( + mut busy: watch::Receiver, + mut enabled: watch::Receiver, + mut hold: impl FnMut(bool), +) { let mut held = false; loop { - let working = *busy.borrow_and_update() > 0; - if working != held { - hold(working); - held = working; + let wanted = *enabled.borrow_and_update() && *busy.borrow_and_update() > 0; + if wanted != held { + hold(wanted); + held = wanted; } - if busy.changed().await.is_err() { + let gone = tokio::select! { + changed = busy.changed() => changed.is_err(), + changed = enabled.changed() => changed.is_err(), + }; + if gone { break; } } @@ -49,24 +59,58 @@ mod tests { use super::*; - #[tokio::test] - async fn holds_only_while_some_bot_is_busy() { - let (count, busy) = watch::channel(0usize); - let calls = Arc::new(Mutex::new(Vec::new())); + type Calls = Arc>>; + + fn start( + busy: watch::Receiver, + enabled: watch::Receiver, + ) -> (tokio::task::JoinHandle<()>, Calls) { + let calls = Calls::default(); let seen = Arc::clone(&calls); - let task = tokio::spawn(follow(busy, move |on| { + let task = tokio::spawn(follow(busy, enabled, move |on| { seen.lock().expect("calls").push(on); })); + (task, calls) + } + + async fn settle() { + tokio::task::yield_now().await; + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + + #[tokio::test] + async fn holds_only_while_some_bot_is_busy() { + let (count, busy) = watch::channel(0usize); + let (_setting, enabled) = watch::channel(true); + let (task, calls) = start(busy, enabled); for n in [1, 2, 1, 0, 0, 3] { count.send_replace(n); - tokio::task::yield_now().await; - tokio::time::sleep(std::time::Duration::from_millis(5)).await; + settle().await; } drop(count); task.await.expect("follow ends with the daemon"); assert_eq!(*calls.lock().expect("calls"), [true, false, true, false]); } + #[tokio::test] + async fn the_owner_can_turn_it_off_and_on_while_bots_work() { + let (count, busy) = watch::channel(2usize); + let (setting, enabled) = watch::channel(false); + let (task, calls) = start(busy, enabled); + settle().await; + assert!(calls.lock().expect("calls").is_empty(), "off: never held"); + setting.send_replace(true); + settle().await; + setting.send_replace(false); + settle().await; + count.send_replace(0); + setting.send_replace(true); + settle().await; + drop(setting); + task.await.expect("follow ends with the daemon"); + assert_eq!(*calls.lock().expect("calls"), [true, false]); + } + #[test] fn a_keep_awake_request_can_be_set_and_cleared() { let mut request = KeepAwake::new().expect("request"); diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs index b4105f28..20e3ac95 100644 --- a/crates/botloftd/src/lib.rs +++ b/crates/botloftd/src/lib.rs @@ -20,6 +20,7 @@ pub mod screens; pub mod secrets; pub mod server; pub mod service; +pub mod settings; pub mod state; pub mod supervisor; pub mod tools; diff --git a/crates/botloftd/src/main.rs b/crates/botloftd/src/main.rs index c8a24150..b7c14979 100644 --- a/crates/botloftd/src/main.rs +++ b/crates/botloftd/src/main.rs @@ -15,6 +15,7 @@ use botloftd::paths::{self, Paths}; use botloftd::platform::{self, InstanceLock}; use botloftd::runtime::PipeRuntime; use botloftd::service::tasks::TaskSettings; +use botloftd::settings::LiveSettings; use botloftd::state::{BotSettings, Daemon, DaemonOptions}; use botloftd::supervisor::{self, SupervisorSettings}; use botloftd::{approvals, autostart, keep_awake, logging, routines, secrets, server}; @@ -39,6 +40,9 @@ enum Command { /// Config file. Defaults to config.toml in the data folder. #[arg(long)] config: Option, + /// Started by the scheduled task (spec 14). + #[arg(long, hide = true)] + scheduled: bool, }, /// Start the daemon at logon, as a scheduled task of the current user. #[command(subcommand)] @@ -53,6 +57,9 @@ enum ServiceCommand { Status, /// Stop the daemon and start it again. Restart, + /// Stop the daemon until Botloft is opened again, or until the next + /// logon if it starts with Windows. + Stop, /// Stop the daemon and remove the task. Bots and data stay. Uninstall, } @@ -73,31 +80,34 @@ fn dispatch(cli: Cli) -> anyhow::Result<()> { let home = paths::resolve_home(cli.home.as_deref()).context("cannot resolve the data folder")?; match cli.command { - Command::Serve { config } => serve(home, config), + Command::Serve { config, scheduled } => serve(home, config, scheduled), Command::Service(ServiceCommand::Install) => autostart::install(&home), Command::Service(ServiceCommand::Status) => autostart::status(&home), Command::Service(ServiceCommand::Restart) => autostart::restart(&home), + Command::Service(ServiceCommand::Stop) => autostart::stop(&home), Command::Service(ServiceCommand::Uninstall) => autostart::uninstall(&home), } } -fn serve(home: PathBuf, config_path: Option) -> anyhow::Result<()> { +fn serve(home: PathBuf, config_path: Option, scheduled: bool) -> anyhow::Result<()> { platform::leave_own_console(); std::fs::create_dir_all(&home).with_context(|| format!("cannot create {}", home.display()))?; - let config = match &config_path { - Some(path) => Config::load(path, true)?, - None => Config::load(&home.join("config.toml"), false)?, - }; + let config_path = config_path.unwrap_or_else(|| home.join("config.toml")); + let config = Config::load(&config_path, config_path != home.join("config.toml"))?; let paths = Paths::new(home, paths::resolve_workspaces_root(&config)?); std::fs::create_dir_all(paths.logs())?; let _log_guard = logging::init(&paths.logs(), &config.log_level)?; // Started by the scheduled task, nobody sees stderr: the log has to // say why the daemon stopped. - run(paths, config).inspect_err(|err| error!("{err:#}")) + run(paths, config, config_path, scheduled).inspect_err(|err| error!("{err:#}")) } -fn run(paths: Paths, config: Config) -> anyhow::Result<()> { +fn run(paths: Paths, config: Config, config_path: PathBuf, scheduled: bool) -> anyhow::Result<()> { let _lock = InstanceLock::acquire(&paths.lock_file())?; + if scheduled && !autostart::scheduled_start(&paths.home, config.start_with_windows) { + info!("a new sign-in, and Botloft does not start with Windows: waiting to be opened"); + return Ok(()); + } let owner_token = secrets::load_or_create_owner_token(&paths.secrets()) .context("cannot prepare the owner token")?; @@ -118,6 +128,7 @@ fn run(paths: Paths, config: Config) -> anyhow::Result<()> { tasks: TaskSettings::from_config(&config), bots: BotSettings::from_config(&config), browser: BrowserSettings::from_config(&config), + settings: LiveSettings::new(Some(config_path), &config), }); // No bot process survived the last run, so nobody waits for these. approvals::expire_all(&daemon); @@ -139,9 +150,10 @@ fn run(paths: Paths, config: Config) -> anyhow::Result<()> { tokio::spawn(courier::run(Arc::clone(&daemon))); tokio::spawn(routines::run(Arc::clone(&daemon))); tokio::spawn(browser::run(Arc::clone(&daemon))); - if config.keep_awake { - tokio::spawn(keep_awake::run(daemon.supervisor.busy_bots())); - } + tokio::spawn(keep_awake::run( + daemon.supervisor.busy_bots(), + daemon.settings.keep_awake(), + )); server::serve(Arc::clone(&daemon), listener, platform::shutdown_signal()).await?; daemon.supervisor.shutdown(); info!("stopped"); diff --git a/crates/botloftd/src/platform/mod.rs b/crates/botloftd/src/platform/mod.rs index 5fb51a5e..752556ac 100644 --- a/crates/botloftd/src/platform/mod.rs +++ b/crates/botloftd/src/platform/mod.rs @@ -13,15 +13,15 @@ use std::path::{Path, PathBuf}; #[cfg(unix)] pub use unix::{ KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, register_task, - restrict_to_current_user, run_task, stop_task, user_environment, + restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment, }; #[cfg(windows)] pub use windows::{ KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, register_task, - restrict_to_current_user, run_task, stop_task, user_environment, + restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment, }; -/// A program the system starts when the owner logs on (spec 14). +/// A program the system starts for the owner (spec 14). #[derive(Debug, Clone, PartialEq, Eq)] pub struct TaskDefinition { pub description: String, @@ -29,6 +29,16 @@ pub struct TaskDefinition { /// Already quoted for the command line. pub arguments: String, pub working_dir: PathBuf, + pub triggers: Triggers, +} + +/// When the system starts the task (spec 14). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Triggers { + /// When the owner signs in to Windows. + pub logon: bool, + /// Every minute, which brings the program back soon after it stops. + pub watchdog: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -44,6 +54,7 @@ pub enum TaskState { pub struct TaskInfo { pub state: TaskState, pub command: Option, + pub triggers: Triggers, } /// Exclusive lock on `\botloftd.lock`, held for the daemon's lifetime. diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs index 738a38e0..1b8e49ac 100644 --- a/crates/botloftd/src/platform/unix.rs +++ b/crates/botloftd/src/platform/unix.rs @@ -102,3 +102,8 @@ pub fn stop_task(_name: &str) -> io::Result<()> { pub fn delete_task(_name: &str) -> io::Result { Err(no_tasks()) } + +/// Unix has no Windows sign-in to tell apart. +pub fn sign_in_id() -> Option { + None +} diff --git a/crates/botloftd/src/platform/windows.rs b/crates/botloftd/src/platform/windows.rs index 426b9876..e2645193 100644 --- a/crates/botloftd/src/platform/windows.rs +++ b/crates/botloftd/src/platform/windows.rs @@ -6,6 +6,7 @@ mod env; mod job; mod owner; mod power; +mod sign_in; mod task; mod task_xml; @@ -14,6 +15,7 @@ pub use env::user_environment; pub use job::ProcessJob; pub use owner::owner_name; pub use power::KeepAwake; +pub use sign_in::sign_in_id; pub use task::{delete_task, find_task, register_task, run_task, stop_task}; use std::io; diff --git a/crates/botloftd/src/platform/windows/sign_in.rs b/crates/botloftd/src/platform/windows/sign_in.rs new file mode 100644 index 00000000..c814eb98 --- /dev/null +++ b/crates/botloftd/src/platform/windows/sign_in.rs @@ -0,0 +1,38 @@ +//! Which Windows sign-in this process runs in, to tell a start after a new +//! sign-in from one in the same sign-in (spec 14). + +use windows::Win32::System::RemoteDesktop::{ + WTS_CURRENT_SERVER_HANDLE, WTS_CURRENT_SESSION, WTSFreeMemory, WTSINFOW, + WTSQuerySessionInformationW, WTSSessionInfo, +}; +use windows::core::PWSTR; + +/// The session and the time the owner signed in to it. The app and the +/// daemon started by the scheduled task see the same one; signing out and +/// in again, or shutting down, gives a new one. `None` if Windows does not +/// say. +pub fn sign_in_id() -> Option { + let mut buffer = PWSTR::null(); + let mut bytes = 0u32; + // SAFETY: on success Windows allocates `buffer`, freed below. + unsafe { + WTSQuerySessionInformationW( + Some(WTS_CURRENT_SERVER_HANDLE), + WTS_CURRENT_SESSION, + WTSSessionInfo, + &mut buffer, + &mut bytes, + ) + } + .ok()?; + if buffer.is_null() { + return None; + } + let whole = bytes as usize >= size_of::(); + // SAFETY: a WTSSessionInfo query fills one `WTSINFOW`, checked by size. + let info = whole.then(|| unsafe { buffer.0.cast::().read_unaligned() }); + // SAFETY: `buffer` came from WTSQuerySessionInformationW. + unsafe { WTSFreeMemory(buffer.0.cast()) }; + let info = info?; + (info.LogonTime != 0).then(|| format!("{}-{}", info.SessionId, info.LogonTime)) +} diff --git a/crates/botloftd/src/platform/windows/task.rs b/crates/botloftd/src/platform/windows/task.rs index dd89a366..83b55e0f 100644 --- a/crates/botloftd/src/platform/windows/task.rs +++ b/crates/botloftd/src/platform/windows/task.rs @@ -88,8 +88,8 @@ impl Scheduler { } } -/// Creates the task, or replaces its definition if it exists. It runs as -/// the current user, only while they are logged on. +/// Creates the task, or replaces its definition if it exists (which also +/// enables it). It runs as the current user, only while they are logged on. pub fn register_task(name: &str, task: &TaskDefinition) -> io::Result<()> { let xml = task_xml::render(task, &CurrentUser::query()?.sid_string()?); let scheduler = Scheduler::connect()?; @@ -123,9 +123,11 @@ pub fn find_task(name: &str) -> io::Result> { TASK_STATE_DISABLED => TaskState::Disabled, _ => TaskState::Unknown, }; + let xml = xml.to_string(); Ok(Some(TaskInfo { state, - command: task_xml::command_line(&xml.to_string()), + command: task_xml::command_line(&xml), + triggers: task_xml::triggers(&xml), })) } diff --git a/crates/botloftd/src/platform/windows/task_xml.rs b/crates/botloftd/src/platform/windows/task_xml.rs index 1966eb1b..868acf66 100644 --- a/crates/botloftd/src/platform/windows/task_xml.rs +++ b/crates/botloftd/src/platform/windows/task_xml.rs @@ -1,19 +1,24 @@ //! The definition of the daemon's scheduled task, in Task Scheduler XML. //! -//! - A logon trigger for the current user starts the daemon at logon. +//! - A logon trigger for the current user starts the daemon at logon, +//! when the owner wants Botloft to start with Windows. //! - A time trigger that began in the past and repeats every minute keeps //! it running: with `IgnoreNew`, a repetition does nothing while the //! daemon runs and starts it again within a minute after it dies. The //! logon trigger cannot repeat for this: its repetition only begins at //! the next logon, not when the task is installed. `RestartOnFailure` //! is not used either: it does not fire when the process exits with an -//! error (both tested on Windows 11 25H2, spec 14). +//! error (both tested on Windows 11 25H2, spec 14). It is left out while +//! the owner has stopped Botloft. //! - Interactive token, least privilege: only while the user is logged on, //! in their session, with their Claude Code sign-in. //! - No time limit, runs on battery, normal priority. The default priority //! (7) would pass "below normal" on to every bot. -use crate::platform::TaskDefinition; +use crate::platform::{TaskDefinition, Triggers}; + +const LOGON_TRIGGER: &str = ""; +const WATCHDOG_TRIGGER: &str = ""; pub(super) fn render(task: &TaskDefinition, user_sid: &str) -> String { let sid = escape(user_sid); @@ -21,6 +26,7 @@ pub(super) fn render(task: &TaskDefinition, user_sid: &str) -> String { let command = escape(&task.program.to_string_lossy()); let arguments = escape(&task.arguments); let working_dir = escape(&task.working_dir.to_string_lossy()); + let triggers = triggers_xml(task.triggers, &sid); format!( r#" @@ -29,19 +35,7 @@ pub(super) fn render(task: &TaskDefinition, user_sid: &str) -> String { {description} - - true - {sid} - - - true - 2026-01-01T00:00:00 - - PT1M - false - - - +{triggers} {sid} @@ -80,6 +74,41 @@ pub(super) fn render(task: &TaskDefinition, user_sid: &str) -> String { ) } +fn triggers_xml(triggers: Triggers, sid: &str) -> String { + let mut xml = String::new(); + if triggers.logon { + xml.push_str(&format!( + r" {LOGON_TRIGGER} + true + {sid} + +" + )); + } + if triggers.watchdog { + xml.push_str(&format!( + r" {WATCHDOG_TRIGGER} + true + 2026-01-01T00:00:00 + + PT1M + false + + +" + )); + } + xml +} + +/// Which triggers a registered task has, from its XML. +pub(super) fn triggers(xml: &str) -> Triggers { + Triggers { + logon: xml.contains(LOGON_TRIGGER), + watchdog: xml.contains(WATCHDOG_TRIGGER), + } +} + /// The command line of the task's first action, as the owner would type it. pub(super) fn command_line(xml: &str) -> Option { let command = unescape(element(xml, "Command")?); @@ -136,6 +165,10 @@ mod tests { program: PathBuf::from(r"C:\Users\Ana Lima\AppData\Local\Botloft\bin\botloftd.exe"), arguments: r#"serve --home "C:\Users\Ana Lima\AppData\Local\Botloft""#.into(), working_dir: PathBuf::from(r"C:\Users\Ana Lima\AppData\Local\Botloft"), + triggers: Triggers { + logon: true, + watchdog: true, + }, } } @@ -155,6 +188,24 @@ mod tests { assert!(xml.contains("InteractiveToken")); assert!(xml.contains("IgnoreNew")); assert!(xml.contains("PT0S")); + assert_eq!(triggers(&xml), task().triggers); + } + + #[test] + fn each_trigger_can_be_left_out() { + for (logon, watchdog) in [(true, false), (false, true), (false, false)] { + let mut task = task(); + task.triggers = Triggers { logon, watchdog }; + let xml = render(&task, "S-1-5-21-1-2-3-1001"); + assert_eq!(triggers(&xml), task.triggers); + assert_eq!(xml.contains("PT1M"), watchdog); + let users = xml.matches("S-1-5-21-1-2-3-1001").count(); + assert_eq!( + users, + if logon { 2 } else { 1 }, + "the principal is always there" + ); + } } #[test] diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index bbeba456..4f62a504 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -9,7 +9,7 @@ use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; use crate::service::{ self, ApiResult, attachments, bots, chat, crews, deliveries, files, lead, messages, models, - modes, routines, screens, tasks, + modes, routines, screens, settings, tasks, }; use crate::state::Daemon; @@ -57,6 +57,8 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(tasks::list(daemon, parse(params)?)), method::BROWSER_LIST => reply(Ok(daemon.browsers.list())), method::SCREENS_LIST => reply(screens::list(daemon, parse(params)?)), + method::SETTINGS_GET => reply(settings::get(daemon)), + method::SETTINGS_UPDATE => reply(settings::update(daemon, parse(params)?)), method::SESSION_HELLO => Err(RpcError::new( error_code::CONFLICT, "this connection is already authenticated", diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index b6da8ae7..7aba88e0 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -13,6 +13,7 @@ pub mod models; pub mod modes; pub mod routines; pub mod screens; +pub mod settings; pub mod tasks; use botloft_core::protocol::{OwnerAccount, PROTOCOL_VERSION, SystemStatus, error_code}; @@ -39,6 +40,8 @@ pub enum ApiError { }, #[error("could not update the workspace: {0}")] Workspace(#[source] std::io::Error), + #[error("could not save the settings: {0}")] + Settings(String), #[error("internal error")] Internal(#[source] StoreError), } @@ -49,7 +52,9 @@ impl ApiError { Self::NotFound(_) => error_code::NOT_FOUND, Self::Conflict(_) => error_code::CONFLICT, Self::Validation(_) | Self::Rule { .. } => error_code::VALIDATION, - Self::Workspace(_) | Self::Internal(_) => error_code::INTERNAL_ERROR, + Self::Workspace(_) | Self::Settings(_) | Self::Internal(_) => { + error_code::INTERNAL_ERROR + } } } diff --git a/crates/botloftd/src/service/settings.rs b/crates/botloftd/src/service/settings.rs new file mode 100644 index 00000000..9f2b9f04 --- /dev/null +++ b/crates/botloftd/src/service/settings.rs @@ -0,0 +1,36 @@ +//! `settings.get` and `settings.update` (spec 11.2): what the owner +//! changes in the app's Settings. + +use botloft_core::protocol::{Settings, SettingsUpdateParams}; +use tracing::info; + +use super::{ApiError, ApiResult}; +use crate::autostart; +use crate::state::Daemon; + +pub fn get(daemon: &Daemon) -> ApiResult { + Ok(daemon.settings.get()) +} + +/// Saves the change in `config.toml` and applies it: the scheduled task +/// follows starting with Windows (spec 14), and keeping the computer +/// awake turns on or off at once. +pub fn update(daemon: &Daemon, params: SettingsUpdateParams) -> ApiResult { + let before = daemon.settings.get(); + if let Some(start) = params.start_with_windows + && start != before.start_with_windows + { + autostart::set_start_with_windows(&daemon.paths.home, start) + .map_err(|err| ApiError::Settings(format!("{err:#}")))?; + } + let after = daemon + .settings + .save(params) + .map_err(|err| ApiError::Settings(err.to_string()))?; + info!( + start_with_windows = after.start_with_windows, + keep_awake = after.keep_awake, + "settings changed" + ); + Ok(after) +} diff --git a/crates/botloftd/src/settings.rs b/crates/botloftd/src/settings.rs new file mode 100644 index 00000000..e08c1e93 --- /dev/null +++ b/crates/botloftd/src/settings.rs @@ -0,0 +1,157 @@ +//! The settings the owner changes in the app (spec 6, 11.2). They live in +//! `config.toml` and are written back in place, so the owner's own lines +//! and comments stay, and they take effect at once. + +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard}; + +use botloft_core::protocol::{Settings, SettingsUpdateParams}; +use tokio::sync::watch; + +use crate::config::Config; + +pub struct LiveSettings { + /// Where they are saved; `None` keeps them in memory (tests). + file: Option, + current: Mutex, + keep_awake: watch::Sender, +} + +impl LiveSettings { + pub fn new(file: Option, config: &Config) -> Self { + let current = Settings { + start_with_windows: config.start_with_windows, + keep_awake: config.keep_awake, + }; + Self { + file, + current: Mutex::new(current), + keep_awake: watch::Sender::new(current.keep_awake), + } + } + + pub fn get(&self) -> Settings { + *self.lock() + } + + /// Follows `keep_awake` (spec 14). + pub fn keep_awake(&self) -> watch::Receiver { + self.keep_awake.subscribe() + } + + /// Saves the settings in `update` and applies them. + pub fn save(&self, update: SettingsUpdateParams) -> io::Result { + let mut current = self.lock(); + if let Some(file) = &self.file { + write(file, update)?; + } + if let Some(start) = update.start_with_windows { + current.start_with_windows = start; + } + if let Some(keep_awake) = update.keep_awake { + current.keep_awake = keep_awake; + self.keep_awake.send_replace(keep_awake); + } + Ok(*current) + } + + fn lock(&self) -> MutexGuard<'_, Settings> { + self.current + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +/// Sets the changed keys in `config.toml` and leaves the rest as it was. +/// The new file replaces the old one whole, so a crash never leaves half. +fn write(file: &Path, update: SettingsUpdateParams) -> io::Result<()> { + let text = match std::fs::read_to_string(file) { + Ok(text) => text, + Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(), + Err(err) => return Err(err), + }; + let mut config: toml_edit::DocumentMut = text + .parse() + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + if let Some(start) = update.start_with_windows { + config["start_with_windows"] = toml_edit::value(start); + } + if let Some(keep_awake) = update.keep_awake { + config["keep_awake"] = toml_edit::value(keep_awake); + } + let staged = file.with_extension("toml.new"); + std::fs::write(&staged, config.to_string())?; + std::fs::rename(&staged, file) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn saving_keeps_the_owners_lines_and_comments() { + let dir = tempfile::tempdir().expect("dir"); + let file = dir.path().join("config.toml"); + let mine = + "# my port\nport = 45999\n\n[bots]\napproval_timeout_minutes = 30 # half an hour\n"; + std::fs::write(&file, mine).expect("write"); + let settings = LiveSettings::new(Some(file.clone()), &Config::parse(mine).expect("parse")); + let mut awake = settings.keep_awake(); + + let saved = settings + .save(SettingsUpdateParams { + keep_awake: Some(false), + ..SettingsUpdateParams::default() + }) + .expect("save"); + assert_eq!( + saved, + Settings { + start_with_windows: true, + keep_awake: false + } + ); + assert!(awake.has_changed().expect("sender") && !*awake.borrow_and_update()); + + let text = std::fs::read_to_string(&file).expect("read"); + assert!(text.starts_with("# my port\nport = 45999\nkeep_awake = false\n")); + assert!(text.contains("approval_timeout_minutes = 30 # half an hour")); + assert!(!text.contains("start_with_windows"), "only what changed"); + let config = Config::parse(&text).expect("still valid"); + assert!(!config.keep_awake && config.start_with_windows && config.port == 45999); + + settings + .save(SettingsUpdateParams { + start_with_windows: Some(false), + keep_awake: Some(true), + }) + .expect("save"); + let config = Config::parse(&std::fs::read_to_string(&file).expect("read")).expect("parse"); + assert!(config.keep_awake && !config.start_with_windows); + assert_eq!( + settings.get(), + Settings { + start_with_windows: false, + keep_awake: true + } + ); + } + + #[test] + fn a_missing_file_is_created_with_just_the_change() { + let dir = tempfile::tempdir().expect("dir"); + let file = dir.path().join("config.toml"); + let settings = LiveSettings::new(Some(file.clone()), &Config::default()); + settings + .save(SettingsUpdateParams { + start_with_windows: Some(false), + ..SettingsUpdateParams::default() + }) + .expect("save"); + assert_eq!( + std::fs::read_to_string(&file).expect("read"), + "start_with_windows = false\n" + ); + } +} diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs index 7d75d7a5..3fefc9b0 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -22,6 +22,7 @@ use crate::runtime::Runtime; use crate::screens::Screens; use crate::secrets::TokenHash; use crate::service::tasks::TaskSettings; +use crate::settings::LiveSettings; use crate::supervisor::{Supervisor, SupervisorSettings}; use crate::workspace::WorkspaceEnv; @@ -80,6 +81,8 @@ pub struct DaemonOptions { pub tasks: TaskSettings, pub bots: BotSettings, pub browser: BrowserSettings, + /// What the owner changes in the app's Settings. + pub settings: LiveSettings, } pub struct Daemon { @@ -89,6 +92,7 @@ pub struct Daemon { pub courier: Courier, pub tasks: TaskSettings, pub bots: BotSettings, + pub settings: LiveSettings, pub approvals: Approvals, pub routines: Routines, pub browsers: Browsers, @@ -118,6 +122,7 @@ impl Daemon { courier: Courier::new(options.courier), tasks: options.tasks, bots: options.bots, + settings: options.settings, approvals: Approvals::default(), routines: Routines::default(), browsers: Browsers::new( diff --git a/crates/botloftd/tests/common/mod.rs b/crates/botloftd/tests/common/mod.rs index d55bf75a..c6468bfc 100644 --- a/crates/botloftd/tests/common/mod.rs +++ b/crates/botloftd/tests/common/mod.rs @@ -20,6 +20,7 @@ use std::time::Duration; use botloft_store::Store; use botloftd::browser::BrowserSettings; use botloftd::clock::ManualClock; +use botloftd::config::Config; use botloftd::courier::{self, CourierSettings}; use botloftd::paths::Paths; use botloftd::runtime::claude::Claude; @@ -27,6 +28,7 @@ use botloftd::runtime::fake::FakeRuntime; use botloftd::secrets::TokenHash; use botloftd::server; use botloftd::service::tasks::TaskSettings; +use botloftd::settings::LiveSettings; use botloftd::state::{BotSettings, Daemon, DaemonOptions}; use botloftd::supervisor::{self, ClaudeSource, SupervisorSettings}; use futures_util::{SinkExt, StreamExt}; @@ -112,6 +114,7 @@ pub fn new_daemon(settings: SupervisorSettings) -> Parts { }, bots: bot_settings(), browser: BrowserSettings::default(), + settings: LiveSettings::new(None, &Config::default()), }); Parts { daemon, diff --git a/crates/botloftd/tests/settings.rs b/crates/botloftd/tests/settings.rs new file mode 100644 index 00000000..e1893fef --- /dev/null +++ b/crates/botloftd/tests/settings.rs @@ -0,0 +1,49 @@ +//! The app's Settings (spec 11.2): read and changed over RPC, applied at +//! once. The test daemon has no scheduled task and no `config.toml`, so +//! only the values and the sign-in record change. + +mod common; + +use common::TestDaemon; +use serde_json::json; + +#[tokio::test] +async fn settings_are_read_and_changed_one_at_a_time() { + let t = TestDaemon::start().await; + std::fs::create_dir_all(&t.paths.home).expect("home"); + let mut app = t.session().await; + let settings = app.call("settings.get", json!(null)).await.expect("get"); + assert_eq!( + settings, + json!({ "startWithWindows": true, "keepAwake": true }) + ); + + let changed = app + .call("settings.update", json!({ "keepAwake": false })) + .await + .expect("update"); + assert_eq!( + changed, + json!({ "startWithWindows": true, "keepAwake": false }) + ); + assert!(!*t.daemon.settings.keep_awake().borrow()); + + let changed = app + .call("settings.update", json!({ "startWithWindows": false })) + .await + .expect("update"); + assert_eq!( + changed, + json!({ "startWithWindows": false, "keepAwake": false }) + ); + assert_eq!( + app.call("settings.get", json!(null)).await.expect("get"), + changed + ); + if botloftd::platform::sign_in_id().is_some() { + assert!( + t.paths.home.join("signin").is_file(), + "the daemon runs in this sign-in" + ); + } +} diff --git a/docs/spec.md b/docs/spec.md index 6516f9c4..a15551c8 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -141,6 +141,7 @@ Identidade e instruções vão em `.claude/rules/botloft.md` e não na linha de port = 45710 # só 127.0.0.1 no MVP workspaces_root = "" # vazio = %USERPROFILE%\Botloft claude_path = "" # vazio = resolver pelo PATH +start_with_windows = true # o daemon sobe quando o dono entra no Windows (14) keep_awake = true # impede suspensão enquanto houver bot busy log_level = "info" @@ -171,6 +172,8 @@ max_hops = 4 default_deadline_minutes = 120 ``` +`start_with_windows` e `keep_awake` também mudam pelas Configurações do app (`settings.update`, 11.2): o daemon grava no próprio arquivo com `toml_edit`, só a chave que mudou, e as linhas e comentários do dono ficam. O arquivo novo substitui o antigo inteiro (`config.toml.new` renomeado), para uma queda no meio não deixar metade. As duas valem na hora: `keep_awake` liga ou desliga o pedido de energia, e `start_with_windows` muda os gatilhos da tarefa (14). + ## 7. Ciclo de vida do bot ### 7.1 Estados @@ -501,6 +504,8 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `tasks.list` | `crewId?, status?` | `Task[]` | | `browser.list`, `browser.watch`, `browser.unwatch`, `browser.take`, `browser.release`, `browser.input` | seção 21.7 | o navegador dos bots, a tela ao vivo e o dono no controle | | `screens.list` | seção 22.4 | as telas HTML do bot | +| `settings.get` | | `Settings`: `startWithWindows` e `keepAwake`, como estão no `config.toml` (6) | +| `settings.update` | `startWithWindows?, keepAwake?` | `Settings`; grava o que veio e aplica na hora (6, 14). Se a tarefa não pode mudar, nada é gravado e volta um erro | `Crew` traz `workFolder`, o caminho da pasta de trabalho (a escolhida ou a `shared\`), `workFolderChosen` e `leadBotId`, o chefe (10.2). `Bot` traz também `permissionMode`, `model` e `modelInUse` (7.4) e `lastActivity`: o último item do chat resumido em uma linha, para a lista de conversas: `kind` (`owner`, `message`, `reply`, `tool`, `approval`, `notice`), `text` e `at`. O `text` não tem palavras do daemon: a mensagem do dono vem sem "You:" e a aprovação só com o nome da ferramenta, e o app completa no idioma do dono. @@ -557,12 +562,13 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations | Tema | Solução | |---|---| -| Iniciar com o Windows | `botloftd service install` registra uma **Tarefa Agendada por usuário**, pela API COM do Agendador (as mensagens do `schtasks.exe` são traduzidas e não dá para lê-las). Dois gatilhos: "ao fazer logon" do usuário, que sobe o daemon na hora, e um gatilho de horário com início no passado repetido **a cada 1 min**, que o traz de volta se ele morrer: com `MultipleInstancesPolicy = IgnoreNew`, a repetição não faz nada enquanto o daemon roda. Token interativo e privilégio mínimo (só com o usuário logado, na sessão dele), sem limite de execução, roda na bateria, prioridade 5 (a padrão, 7, passaria "abaixo do normal" para todos os bots). A ação é `\bin\botloftd.exe serve --home `. Não usar Windows Service: roda em outra sessão e sem acesso à autenticação do Claude Code do usuário. Subcomandos `service status`, `service restart`, `service uninstall` (os dados ficam). | +| Iniciar com o Windows | `botloftd service install` registra uma **Tarefa Agendada por usuário**, pela API COM do Agendador (as mensagens do `schtasks.exe` são traduzidas e não dá para lê-las). Dois gatilhos: "ao fazer logon" do usuário, que sobe o daemon na hora, e um gatilho de horário com início no passado repetido **a cada 1 min**, que o traz de volta se ele morrer: com `MultipleInstancesPolicy = IgnoreNew`, a repetição não faz nada enquanto o daemon roda. Token interativo e privilégio mínimo (só com o usuário logado, na sessão dele), sem limite de execução, roda na bateria, prioridade 5 (a padrão, 7, passaria "abaixo do normal" para todos os bots). A ação é `\bin\botloftd.exe serve --home --scheduled`. Não usar Windows Service: roda em outra sessão e sem acesso à autenticação do Claude Code do usuário. Subcomandos `service status`, `service restart`, `service stop`, `service uninstall` (os dados ficam). | +| Escolhas do dono | As Configurações do app decidem os gatilhos. **Rodando:** o de 1 min, mais o de logon se `start_with_windows` (6). **Parado pelo dono** (`service stop`, que o app chama ao fechar quando o dono escolheu parar os bots, 15.1): só o de logon, se `start_with_windows`, e nada o traz de volta antes disso. `install` e `restart` registram os gatilhos de rodando. O de 1 min também dispara depois de um logon novo; por isso, sem `start_with_windows`, o daemon iniciado pela tarefa (`--scheduled`) confere se é um logon novo: `\signin` guarda o logon em que o Botloft rodou pela última vez (sessão e hora do logon, `WTSQuerySessionInformationW` com `WTSSessionInfo`, iguais para o app e para a tarefa), escrito por `install`, `restart`, pelo daemon ao subir e ao mudar a escolha. Logon novo: ele tira os gatilhos da tarefa e sai, até o app abrir e rodar `install`. Logon igual (o daemon morreu e voltou pelo gatilho de 1 min): roda. Iniciado pelo logon depois de parado pelo dono, o daemon devolve o gatilho de 1 min. Logon desconhecido conta como o mesmo, para o daemon nunca deixar de rodar por não saber | | Reinício da tarefa (testado no Windows 11 25H2) | `RestartOnFailure` **não** reinicia a tarefa quando o processo sai com código de erro, nem numa execução por gatilho de horário nem numa sob demanda. A repetição de um gatilho de logon só começa no próximo logon, não quando a tarefa é registrada. Por isso o gatilho de horário faz o papel de vigia: morto o daemon, ele voltou em 43 s. **Reboot** (0.2.0, 2026-09-29): o daemon subiu 1 s depois do logon, pelo gatilho de logon, com o app fechado, e o bot voltou 13 s depois | | Uma tarefa por pasta de dados | `Botloft` para `%LOCALAPPDATA%\Botloft`; `Botloft-<8 hex do SHA-256 do caminho>` para outra pasta (`--home` ou `BOTLOFT_HOME` de dev), para instalar um daemon de dev sem tocar no real. `--home` vale para todos os subcomandos e vem antes de `BOTLOFT_HOME` | | Binário instalado | `service install` copia o próprio executável para `\bin\botloftd.exe` e a tarefa roda essa cópia, nunca a do app: assim o instalador do app troca os arquivos dele com o daemon rodando. Um exe em uso não pode ser sobrescrito mas pode ser renomeado, então o antigo vai para `botloftd..old` e é apagado numa instalação seguinte. Se o binário não mudou e o daemon dessa versão já roda pela tarefa, `install` não o reinicia; senão para a tarefa, espera o `/health` sumir, inicia de novo e espera o `/health` com a nova versão (20 s) | | Janela de console | O manifesto do daemon pede `consoleAllocationPolicy = detached` (Windows 11 24H2 e depois): iniciado pela tarefa, ele não ganha console nem janela; num terminal, continua usando o console do terminal. Em Windows mais antigo, `serve` larga o console se for o único processo nele (`FreeConsole`). O manifesto entra como recurso (`embed-resource`), porque a ferramenta de manifesto do linker não conhece o elemento e avisa a cada build | -| Não suspender | Um power request (`PowerCreateRequest` + `PowerSetRequest(PowerRequestSystemRequired)`, motivo "Botloft bots are working") enquanto houver bot `busy`, se `keep_awake = true`. Bot esperando aprovação não conta. Diferente de `SetThreadExecutionState`, o pedido é de um handle e não de uma thread, então qualquer worker do Tokio o liga e desliga, e `powercfg /requests` o mostra. Não impede a suspensão pedida pelo usuário (tampa, menu Iniciar) | +| Não suspender | Um power request (`PowerCreateRequest` + `PowerSetRequest(PowerRequestSystemRequired)`, motivo "Botloft bots are working") enquanto houver bot `busy`, se `keep_awake = true` (o dono muda nas Configurações e vale na hora). Bot esperando aprovação não conta. Diferente de `SetThreadExecutionState`, o pedido é de um handle e não de uma thread, então qualquer worker do Tokio o liga e desliga, e `powercfg /requests` o mostra. Não impede a suspensão pedida pelo usuário (tampa, menu Iniciar) | | Processos órfãos | Job Object por bot (seção 7.3) | | Encerramento | `ctrl_c`, `ctrl_close`, `ctrl_shutdown`, `ctrl_logoff` do Tokio: parar courier, sinalizar bots, flush do banco. Esses sinais são de console: iniciado pela tarefa, sem console, o daemon não tem garantia de recebê-los, e o Windows encerra o processo no logoff e no desligamento. Isso equivale a um crash, e o sistema já é feito para ele (mensagens gravadas antes de sair, bots mortos pelo Job Object, aprovações abertas expiram no próximo start). Um erro fatal na subida vai para o log, porque ninguém vê o stderr da tarefa | | Caminhos longos | manifesto `longPathAware` no daemon e no app; usar APIs com `\\?\` ao apagar árvores de workspace | @@ -613,7 +619,8 @@ O store recarrega crews, bots e `system.status` a cada (re)conexão e depois seg Layout, como um app de mensagens: - **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto) e a prévia da última atividade (`lastActivity`) com a hora. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. -- **Área da conta**, no pé da barra lateral, como nos apps de chat: um círculo com a inicial, o nome do dono (do Windows) e o plano do Claude ("Plano Max"; sem plano, a organização ou o e-mail; desconectado, "Sem conta do Claude conectada"). Um clique abre um menu para cima com o e-mail e: **Uso** (as janelas de uso do plano, 8.1, com a parte usada e quando renovam; antes da primeira resposta de um bot, um aviso de que ainda não há dados), **Configurações** (tema, tamanho, idioma e as versões do Botloft e do Claude Code), **Idioma** (submenu ao lado, com a escolha na hora), **Novidades** (a página de releases no navegador) e **Ajuda** (o README no navegador). Tema, tamanho e idioma saíram da barra de título; ela só mostra o idioma nas telas de preparo, que ainda não têm barra lateral. +- **Área da conta**, no pé da barra lateral, como nos apps de chat: um círculo com a inicial, o nome do dono (do Windows) e o plano do Claude ("Plano Max"; sem plano, a organização ou o e-mail; desconectado, "Sem conta do Claude conectada"). Um clique abre um menu para cima com o e-mail e: **Uso** (as janelas de uso do plano, 8.1, com a parte usada e quando renovam; antes da primeira resposta de um bot, um aviso de que ainda não há dados), **Configurações** (abaixo), **Idioma** (submenu ao lado, com a escolha na hora), **Novidades** (a página de releases no navegador) e **Ajuda** (o README no navegador). Tema, tamanho e idioma saíram da barra de título; ela só mostra o idioma nas telas de preparo, que ainda não têm barra lateral. +- **Configurações**, um diálogo com quatro partes. **Em segundo plano**, três chaves, cada uma com uma linha que diz o que a escolha atual faz: "Continuar trabalhando depois de fechar o Botloft" (ligada, os bots seguem depois que a janela fecha; desligada, fechar a janela a esconde, chama `daemon_stop` e fecha, e os bots continuam de onde pararam quando o app abre de novo, com "Iniciando seus bots…" no lugar de "Preparando o Botloft…"; a escolha é do app, no `localStorage`, `botloft.whenClosed`), "Iniciar com o Windows" (`startWithWindows`, 14) e "Não deixar o computador dormir enquanto os bots trabalham" (`keepAwake`, com a linha de que a tampa e o Suspender ainda põem o computador para dormir). As duas do daemon vêm de `settings.get` ao abrir, mudam na hora na tela e voltam atrás, com um aviso, se `settings.update` falhar. **Aparência** (tema e tamanho), **Idioma** e **Sobre**: as versões do Botloft e do Claude Code, o e-mail e "Procurar atualizações", que consulta o feed na hora (15.5) e diz que esta é a versão mais recente ou mostra "Ver atualização", que abre o diálogo de atualizar. A linha do Botloft na tela de boas-vindas segue as mesmas escolhas. - **Área principal com um bot:** cabeçalho com nome, estado e ações; o chat; o compositor embaixo. O compositor aceita texto, colar imagem e arrastar ou escolher arquivos. Enter envia e Shift+Enter quebra linha (a dica aparece enquanto o dono escreve). - **Modo do bot**, no compositor, ao lado do clipe, como no Claude Code: um botão com o modo atual abre um menu para cima, "Modo", com Automático, Manual, Aceitar edições e Plano, cada um com uma linha que fala do bot pelo nome ("Scout decide o que precisa do seu OK") e a marca no atual. Separado, "Ignorar permissões" com o botão Ativar, que abre uma confirmação dizendo que o bot não fica preso à pasta dele (13). Nesse modo o botão fica vermelho e o cabeçalho mostra "Não pergunta nada" em vermelho. Com o bot ocupado, uma linha acima do compositor avisa que ele muda de modo quando terminar o que está fazendo. - **Modelo do bot**, no compositor, ao lado do botão de enviar: mostra o modelo em uso ("Opus 5.5"; antes do primeiro turno, o nome escolhido ou "Padrão") e abre um menu para cima, "Modelo", com Padrão do plano, Fable, Opus, Sonnet e Haiku. Cada um tem uma linha que fala do bot pelo nome ("Scout fica rápido e capaz, bom para quase tudo"; o padrão diz qual modelo é hoje), e o pé do menu lembra que modelos mais capazes gastam o limite do plano mais rápido. A janela de criar e editar bot tem a mesma escolha. Com o bot ocupado, a mesma linha acima do compositor avisa que ele troca quando terminar. @@ -631,6 +638,8 @@ Layout, como um app de mensagens: | `daemon_status` | GET `/health` local; diz se o daemon roda, está parado ou se outro programa ocupa a porta. Quando o daemon roda, diz também se ele é `outdated`: versão menor que a do app, que é a do sidecar (um workspace Cargo só). Pré-release é ignorado; versão ilegível nunca é antiga | | `daemon_install` | roda `botloftd --home service install` a partir do sidecar (o `botloftd.exe` ao lado do app), sem janela, e espera: ele se copia para `\bin`, registra e inicia a tarefa e espera o `/health` (seção 14). Um erro volta na mensagem de uma linha que o daemon escreve no stderr. Substitui o `daemon_start` do M4, que iniciava o daemon destacado ao lado do app | | `daemon_restart` | `botloftd --home service restart`, do mesmo jeito | +| `daemon_stop` | `botloftd --home service stop`, do mesmo jeito: para o daemon até o app abrir de novo, ou até o próximo logon se ele inicia com o Windows (14) | +| fechar a janela | não é comando próprio: o app ouve `onCloseRequested` da janela (botão, Alt+F4, barra de tarefas; permissões `core:window:allow-hide` e `core:window:allow-destroy`) e, se o dono escolheu parar os bots ao fechar, esconde a janela, chama `daemon_stop` e deixa fechar, mesmo se ele falhar | | `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 | | `read_owner_token` | lê `secrets\owner.token` (só no app local) | | `open_path` | abre uma pasta no Explorer; recusa arquivos, que o Explorer executaria | @@ -644,7 +653,7 @@ O app acha o daemon como o daemon acha a si mesmo (seção 5): `BOTLOFT_HOME` ou Onboarding (M5): **configuração sem perguntas e sem jargão.** O dono não precisa saber que existe um daemon, uma porta ou uma tarefa agendada; a interface nunca usa essas palavras. Fala de "Botloft" e de "rodar em segundo plano", e o texto técnico (erro do daemon, caminho, porta) fica dobrado sob "Details". -- Nada rodando: o app chama `daemon_install` sozinho ("Getting Botloft ready…", com uma linha dizendo que o Botloft segue rodando em segundo plano depois que a janela fecha e inicia com o Windows). Se falhar, "Botloft couldn't start" com "Try again". +- Nada rodando: o app chama `daemon_install` sozinho ("Getting Botloft ready…", com uma linha dizendo que o Botloft roda os bots em segundo plano e que nas Configurações o dono escolhe se eles seguem depois que a janela fecha e se o Botloft inicia com o Windows). Se o dono escolheu parar os bots ao fechar, é "Starting your bots…", sem a linha. Se falhar, "Botloft couldn't start" com "Try again". - Daemon `outdated`: o app o atualiza sozinho ("Updating Botloft…"), porque app e daemon são distribuídos juntos e quem atualizou o app espera o daemon novo. Os bots em turno são interrompidos e retomam a sessão (7.3). Se falhar ou a versão não mudar, "Botloft couldn't finish updating" com "Try again". - Cada verificação instala ou atualiza no máximo uma vez: uma falha aparece em vez de virar loop. - Daemon mais novo que o app com outro protocolo: pede para instalar a versão mais recente do Botloft.