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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions app/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions app/src-tauri/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ pub fn restart(endpoint: &Endpoint) -> Result<DaemonStatus, String> {
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 <home> service <command>` without a window and
/// returns its error message if it fails.
fn run_service(endpoint: &Endpoint, command: &str) -> Result<(), String> {
Expand Down
9 changes: 9 additions & 0 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ async fn daemon_restart() -> Result<DaemonStatus, String> {
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]
Expand Down Expand Up @@ -158,6 +166,7 @@ pub fn run() {
daemon_status,
daemon_install,
daemon_restart,
daemon_stop,
claude_sign_in,
read_owner_token,
open_path,
Expand Down
2 changes: 2 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(() => {
Expand Down
70 changes: 70 additions & 0 deletions app/src/features/account/AboutSettings.tsx
Original file line number Diff line number Diff line change
@@ -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<Check>({ 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 (
<Section title={s.about}>
<ul className="text-ink-soft text-sm leading-relaxed" data-selectable>
{system && <li>{s.botloft(system.daemonVersion)}</li>}
{system?.claudeVersion && <li>{s.claudeCode(system.claudeVersion)}</li>}
{system?.account.claude?.email && <li>{system.account.claude.email}</li>}
</ul>
<div className="flex flex-wrap items-center gap-3">
{check.stage === "found" ? (
<Button variant="primary" onClick={() => setOpen(true)}>
{s.seeUpdate}
</Button>
) : (
<Button icon={RefreshCw} onClick={look} disabled={check.stage === "checking"}>
{s.checkUpdates}
</Button>
)}
<p role="status" className="text-muted text-sm">
{said}
</p>
</div>
{open && check.stage === "found" && (
<UpdateDialog update={check.update} onClose={() => setOpen(false)} />
)}
</Section>
);
}
40 changes: 40 additions & 0 deletions app/src/features/account/BackgroundSettings.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Section title={s.background}>
<Toggle
label={s.keepWorking}
hint={whenClosed === "keep" ? s.keepWorkingOn : s.keepWorkingOff}
checked={whenClosed === "keep"}
onChange={(on) => setWhenClosed(on ? "keep" : "stop")}
/>
<Toggle
label={s.startWithWindows}
hint={startWithWindows ? s.startWithWindowsOn : s.startWithWindowsOff}
checked={startWithWindows}
disabled={!settings}
onChange={(on) => change({ startWithWindows: on })}
/>
<Toggle
label={s.keepAwake}
hint={s.keepAwakeHint}
checked={settings?.keepAwake ?? true}
disabled={!settings}
onChange={(on) => change({ keepAwake: on })}
/>
</Section>
);
}
39 changes: 8 additions & 31 deletions app/src/features/account/SettingsDialog.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -12,17 +12,18 @@ 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();
const s = t.account.settings;
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 }[] = [
Expand All @@ -42,6 +43,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) {
return (
<Dialog title={s.title} onClose={onClose} width="lg">
<div className="flex flex-col gap-6">
<BackgroundSettings />
<Section title={s.appearance}>
<Field label={s.theme}>
<Choices label={s.theme} value={theme} options={themes} onChange={setTheme} />
Expand All @@ -58,33 +60,8 @@ export function SettingsDialog({ onClose }: { onClose(): void }) {
onChange={setLocaleChoice}
/>
</Section>
<Section title={s.about}>
<ul className="text-ink-soft text-sm leading-relaxed" data-selectable>
{system && <li>{s.botloft(system.daemonVersion)}</li>}
{system?.claudeVersion && <li>{s.claudeCode(system.claudeVersion)}</li>}
{system?.account.claude?.email && <li>{system.account.claude.email}</li>}
</ul>
</Section>
<AboutSettings />
</div>
</Dialog>
);
}

function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="flex flex-col gap-3">
<h3 className="font-semibold text-muted text-xs uppercase tracking-[0.12em]">{title}</h3>
{children}
</section>
);
}

function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<p className="font-medium text-sm">{label}</p>
{children}
{hint && <p className="text-muted text-xs">{hint}</p>}
</div>
);
}
133 changes: 133 additions & 0 deletions app/src/features/account/settings.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<App host={host} connect={() => 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();
});
});
Loading
Loading