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
1 change: 1 addition & 0 deletions Cargo.lock

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

10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ you decide what they may do.
- **Choose per bot.** How much it asks before acting, and which Claude model it uses. See how much
of your plan's usage is left.
- **Back up and move.** Save a sealed backup file, or sign in with your e-mail (no password) to keep
a light copy in the cloud and restore it on another computer. The server cannot read it.
a light copy in the cloud and restore it on another computer. The server cannot read it. Turn on
automatic copies, every day or week and only when something changed, if you want them to go by
themselves (Windows for now).

<table>
<tr>
Expand Down Expand Up @@ -180,8 +182,10 @@ You sign in with a link sent to your e-mail; there is no account password. The c
light one: your crews' structure, the bots' memory and the routines, not the chats or files. Botloft
seals it on your computer with the backup password before sending, so the server stores bytes it
cannot open. It also knows your e-mail, the computers you signed in on, and the size and date of each
copy. Lose the backup password and the cloud copies cannot be recovered. Nothing is sent without a
click, and you can delete the copies or the whole account from the app. The server is this
copy. Lose the backup password and the cloud copies cannot be recovered. Nothing is sent unless you
send it or turn on automatic copies. Those go every day or week, only when something changed; for
them Windows keeps your backup password in its credential store, on this computer only, so use a
password just for this. You can delete the copies or the whole account from the app. The server is this
repository's `botloft-cloud` crate: you can [run your own](docs/cloud-deploy.md) and point the app at
it.
</details>
Expand Down
105 changes: 105 additions & 0 deletions app/src/features/account/CloudAuto.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Copies sent by themselves (spec 27.10): every day or week, and only when
// something changed. The passphrase goes to the computer's credential store.

import { useState } from "react";
import { useT } from "../../i18n";
import { when } from "../../lib/format";
import type { AutoBackupEvery } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { Select } from "../../ui/Select";
import { backupError } from "./backupError";
import { PassphraseFields, usePassphrase } from "./PassphraseFields";
import { useAutoBackup } from "./useAutoBackup";

export function CloudAuto() {
const t = useT().account.cloud;
const a = t.auto;
const api = useApi();
const { status, setStatus } = useAutoBackup();
const passphrase = usePassphrase();
const [every, setEvery] = useState<AutoBackupEvery>("daily");
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<string | null>(null);

if (!status) {
return null;
}
const options: { value: AutoBackupEvery; label: string }[] = [
{ value: "daily", label: a.daily },
{ value: "weekly", label: a.weekly },
];

const run = async (work: () => Promise<void>) => {
setBusy(true);
setProblem(null);
try {
await work();
} catch (failure) {
setProblem(backupError(failure, t.reasons));
}
setBusy(false);
};

const turnOn = () =>
run(async () => {
setStatus(await api.call("autobackup.enable", { passphrase: passphrase.passphrase, every }));
passphrase.clear();
});
const turnOff = () => run(async () => setStatus(await api.call("autobackup.disable")));
const change = (next: AutoBackupEvery) =>
run(async () => setStatus(await api.call("autobackup.set_every", { every: next })));

return (
<div className="flex flex-col gap-3 border-line border-t pt-3">
<h4 className="font-medium text-sm">{a.title}</h4>
{!status.available ? (
<p className="text-muted text-sm">{a.unavailable}</p>
) : status.enabled ? (
<>
<p role="status" className="text-ok text-sm">
{status.every === "weekly" ? a.onWeekly : a.onDaily}
</p>
<p className="text-ink-soft text-sm">
{status.lastOkAt !== undefined ? a.lastCopy(when(status.lastOkAt)) : a.noneYet}
{status.nextAt !== undefined && ` ${a.next(when(status.nextAt))}`}
</p>
{status.lastError && (
<Callout tone="danger" title={a.lastFailed}>
{t.reasons[status.lastError] ?? t.reasons.cloud_error}
</Callout>
)}
<div className="flex items-end gap-3">
<Select label={a.every} value={status.every} options={options} onChange={change} />
<Button variant="ghost" disabled={busy} onClick={turnOff}>
{a.turnOff}
</Button>
</div>
</>
) : (
<>
<p className="text-ink-soft text-sm leading-relaxed">{a.intro}</p>
<PassphraseFields value={passphrase} />
<p className="text-muted text-xs">{a.tip}</p>
<div className="flex items-end gap-3">
<Select<AutoBackupEvery>
label={a.every}
value={every}
options={options}
onChange={setEvery}
/>
<Button disabled={!passphrase.ready || busy} onClick={turnOn}>
{busy ? a.turningOn : a.turnOn}
</Button>
</div>
</>
)}
{problem && (
<Callout tone="danger" title={a.failed}>
{problem}
</Callout>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions app/src/features/account/CloudSignedIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { Confirm } from "../../ui/Confirm";
import { backupError } from "./backupError";
import { CloudAuto } from "./CloudAuto";
import { CloudCopies } from "./CloudCopies";
import { CloudUpload } from "./CloudUpload";

Expand Down Expand Up @@ -64,6 +65,7 @@ export function CloudSignedIn({
void refresh();
}}
/>
<CloudAuto />
<CloudCopies version={version} onChanged={refresh} />

<div className="flex flex-col gap-2 border-line border-t pt-3">
Expand Down
133 changes: 133 additions & 0 deletions app/src/features/account/autoBackup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { act, cleanup, configure, fireEvent, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { fakeAutoBackupSent, setFakeAutoBackup } from "../../lib/fakeAutoBackup";
import { openFakeCloudLink } from "../../lib/fakeCloud";
import { FakeHost } from "../../lib/fakeHost";
import { crewOpened, renderApp } from "../../test/app";

afterEach(cleanup);

// Each test signs in and opens menus: a loaded machine needs more than a second.
configure({ asyncUtilTimeout: 5000 });
vi.setConfig({ testTimeout: 20_000 });

const type = (field: HTMLElement, value: string) => fireEvent.change(field, { target: { value } });

/** Settings, Backup, signed in to the account. */
async function signedIn() {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
fake.addBot(ops.id, "Scout");
renderApp(fake, new FakeHost());
await crewOpened("Ops");
fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ }));
fireEvent.click(screen.getByRole("menuitem", { name: "Settings" }));
const dialog = screen.getByRole("dialog", { name: "Settings" });
fireEvent.click(within(dialog).getByRole("tab", { name: "Backup" }));
type(await within(dialog).findByLabelText("Your e-mail"), "ana@exemplo.com");
fireEvent.click(within(dialog).getByRole("button", { name: "Send me the link" }));
await within(dialog).findByText(/We sent a link/);
act(() => openFakeCloudLink(fake));
await within(dialog).findByText("Signed in as ana@exemplo.com");
// The block asks the daemon for its status, so it is there a moment later.
await within(dialog).findByText("Automatic copies");
return { fake, dialog };
}

/** The passphrase fields of the automatic block: the second pair, after the upload's. */
function typePassphrase(dialog: HTMLElement, value: string) {
const first = within(dialog).getAllByLabelText("Passphrase")[1] as HTMLElement;
const again = within(dialog).getAllByLabelText("Passphrase again")[1] as HTMLElement;
type(first, value);
type(again, value);
}

const calls = (fake: FakeBotloft, method: string) =>
fake.calls.filter((call) => call.method === method);

describe("automatic copies", () => {
test("it is off until the owner turns it on with a passphrase and how often", async () => {
const { fake, dialog } = await signedIn();
expect(await within(dialog).findByText("Automatic copies")).toBeDefined();
const turnOn = within(dialog).getByRole("button", { name: "Turn on" });
expect(turnOn).toHaveProperty("disabled", true);

typePassphrase(dialog, "correct horse");
expect(turnOn).toHaveProperty("disabled", false);
fireEvent.click(within(dialog).getByRole("button", { name: "How often: Every day" }));
fireEvent.click(await screen.findByRole("option", { name: "Every week" }));
fireEvent.click(turnOn);

expect(
await within(dialog).findByText("On: a copy every week, if something changed."),
).toBeDefined();
expect(calls(fake, "autobackup.enable").at(-1)?.params).toEqual({
passphrase: "correct horse",
every: "weekly",
});
// The passphrase is not kept on the screen once it is on.
expect(within(dialog).queryByRole("button", { name: "Turn on" })).toBeNull();
});

test("it shows the last copy, the next look and changes how often", async () => {
const { fake, dialog } = await signedIn();
typePassphrase(dialog, "correct horse");
fireEvent.click(within(dialog).getByRole("button", { name: "Turn on" }));
await within(dialog).findByText(/On: a copy every day/);
expect(within(dialog).getByText(/No copy sent yet\./)).toBeDefined();

act(() => fakeAutoBackupSent(fake));
expect(await within(dialog).findByText(/Last copy sent:/)).toBeDefined();
expect(within(dialog).getByText(/Next look:/)).toBeDefined();

fireEvent.click(within(dialog).getByRole("button", { name: "How often: Every day" }));
fireEvent.click(await screen.findByRole("option", { name: "Every week" }));
await within(dialog).findByText(/On: a copy every week/);
expect(calls(fake, "autobackup.set_every").at(-1)?.params).toEqual({ every: "weekly" });
});

test("a failed try says why, in words", async () => {
const { fake, dialog } = await signedIn();
typePassphrase(dialog, "correct horse");
fireEvent.click(within(dialog).getByRole("button", { name: "Turn on" }));
await within(dialog).findByText(/On: a copy every day/);

act(() => setFakeAutoBackup(fake, { lastError: "offline" }));
expect(await within(dialog).findByText("The last try failed")).toBeDefined();
expect(
within(dialog).getByText("Could not reach the cloud. Check your connection and try again."),
).toBeDefined();
});

test("turning it off brings back the passphrase fields", async () => {
const { fake, dialog } = await signedIn();
typePassphrase(dialog, "correct horse");
fireEvent.click(within(dialog).getByRole("button", { name: "Turn on" }));
await within(dialog).findByText(/On: a copy every day/);

fireEvent.click(within(dialog).getByRole("button", { name: "Turn off" }));
expect(await within(dialog).findByRole("button", { name: "Turn on" })).toBeDefined();
expect(calls(fake, "autobackup.disable")).toHaveLength(1);
});

test("where the computer has no credential store it says so and offers nothing", async () => {
const { fake, dialog } = await signedIn();
act(() => setFakeAutoBackup(fake, { available: false }));
expect(
await within(dialog).findByText("Automatic copies work only on Windows for now."),
).toBeDefined();
expect(within(dialog).queryByRole("button", { name: "Turn on" })).toBeNull();
});

test("a short passphrase keeps the button off", async () => {
const { fake, dialog } = await signedIn();
// The screen already holds back a short one; the fake refuses it too.
typePassphrase(dialog, "short");
expect(within(dialog).getByRole("button", { name: "Turn on" })).toHaveProperty(
"disabled",
true,
);
expect(calls(fake, "autobackup.enable")).toHaveLength(0);
});
});
4 changes: 2 additions & 2 deletions app/src/features/account/cloud.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,15 @@ describe("the account and the copies in the cloud", () => {
sendCopy(dialog);
await within(dialog).findByText("Copy sent (47 KB).");

fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
fireEvent.click(await within(dialog).findByRole("button", { name: "Delete" }));
const ask = await screen.findByRole("dialog", { name: "Delete this copy?" });
fireEvent.click(within(ask).getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Delete this copy?" })).toBeNull(),
);
expect(fake.calls.some((each) => each.method === "cloud.delete")).toBe(false);

fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
fireEvent.click(await within(dialog).findByRole("button", { name: "Delete" }));
const again = await screen.findByRole("dialog", { name: "Delete this copy?" });
fireEvent.click(within(again).getByRole("button", { name: "Delete" }));
expect(await within(dialog).findByText("No copies yet.")).toBeDefined();
Expand Down
30 changes: 30 additions & 0 deletions app/src/features/account/useAutoBackup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// What the account block knows of the automatic backup (spec 27.10): its
// status, kept current by the daemon's `autobackup.changed`.

import { useCallback, useEffect, useState } from "react";
import type { AutoBackupStatus } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";

export function useAutoBackup() {
const api = useApi();
const [status, setStatus] = useState<AutoBackupStatus | null>(null);

const refresh = useCallback(async () => {
try {
setStatus(await api.call("autobackup.status"));
} catch {
// The screen keeps what it had.
}
}, [api]);

useEffect(() => {
void refresh();
return api.subscribe((event) => {
if (event.name === "autobackup.changed") {
setStatus(event.params);
}
});
}, [api, refresh]);

return { status, setStatus };
}
23 changes: 23 additions & 0 deletions app/src/i18n/en/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,26 @@ export const account = {
"This deletes your account and every copy saved in it, and it cannot be undone. We send a link to your e-mail, and nothing is deleted until you press the button there.",
removeAccountSend: "Send the link",
removeAccountSent: "We sent a link to your e-mail. Open it and press the button to finish.",
auto: {
title: "Automatic copies",
intro:
"Botloft can send a copy to the cloud by itself, and only when something changed. Your passphrase is kept in Windows' credential store, on this computer only.",
tip: "Use a passphrase just for this, one you do not use anywhere else.",
every: "How often",
daily: "Every day",
weekly: "Every week",
turnOn: "Turn on",
turningOn: "Turning on…",
turnOff: "Turn off",
onDaily: "On: a copy every day, if something changed.",
onWeekly: "On: a copy every week, if something changed.",
lastCopy: (when: string) => `Last copy sent: ${when}.`,
noneYet: "No copy sent yet.",
next: (when: string) => `Next look: ${when}.`,
unavailable: "Automatic copies work only on Windows for now.",
failed: "Could not turn it on",
lastFailed: "The last try failed",
},
failed: "Something went wrong",
reasons: {
no_server: "This Botloft has no cloud set up yet.",
Expand All @@ -150,6 +170,9 @@ export const account = {
not_found: "That copy is no longer there.",
bad_hash: "The copy was damaged on the way. Try again.",
cloud_error: "Something went wrong with the cloud. Try again in a moment.",
no_keystore: "This computer has nowhere safe to keep the passphrase.",
no_passphrase:
"The passphrase is gone from this computer, so automatic copies stopped. Turn them on again.",
} as Record<string, string>,
},
settings: {
Expand Down
Loading
Loading