Skip to content

Commit f1c4e45

Browse files
committed
feat(app): Backup in Settings, to save a copy and restore one
Save a copy: the passphrase twice, then the system's Save dialog for the file the daemon sealed. Restore a copy: the system's Open dialog for a .botloft file, its passphrase, what the copy holds (crews, bots, the work folders that are not in it) and Restore and restart. Errors are worded from the daemon's reasons. The host gains pickFile. Spec 14.2.
1 parent 3ac9a9d commit f1c4e45

11 files changed

Lines changed: 474 additions & 4 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Restoring a copy (spec 14.2): the owner picks the file and types its
2+
// passphrase; the daemon opens it and says what it holds; only after the
3+
// owner confirms does Botloft restart and swap it in.
4+
5+
import { FolderOpen, RotateCcw } from "lucide-react";
6+
import { useState } from "react";
7+
import { useT } from "../../i18n";
8+
import { when } from "../../lib/format";
9+
import type { BackupManifest } from "../../lib/protocol.gen";
10+
import { useApi, useHost } from "../../store/context";
11+
import { Button } from "../../ui/Button";
12+
import { Callout } from "../../ui/Callout";
13+
import { TextField } from "../../ui/Field";
14+
import { backupError } from "./BackupSettings";
15+
16+
export function BackupRestore() {
17+
const b = useT().account.backup;
18+
const api = useApi();
19+
const host = useHost();
20+
const [file, setFile] = useState<string | null>(null);
21+
const [passphrase, setPassphrase] = useState("");
22+
const [manifest, setManifest] = useState<BackupManifest | null>(null);
23+
const [busy, setBusy] = useState(false);
24+
const [failed, setFailed] = useState<{ title: string; text: string } | null>(null);
25+
26+
const pick = async () => {
27+
const picked = await host.pickFile(b.pickTitle, b.kind, ["botloft"]);
28+
if (picked) {
29+
setFile(picked);
30+
setManifest(null);
31+
setFailed(null);
32+
}
33+
};
34+
35+
const open = async () => {
36+
if (!file) {
37+
return;
38+
}
39+
setBusy(true);
40+
setFailed(null);
41+
try {
42+
setManifest(await api.call("backup.stage", { path: file, passphrase }));
43+
} catch (failure) {
44+
setFailed({ title: b.openFailed, text: backupError(failure, b.reasons) });
45+
}
46+
setBusy(false);
47+
};
48+
49+
const restore = async () => {
50+
setBusy(true);
51+
setFailed(null);
52+
try {
53+
await api.call("backup.confirm");
54+
await host.restartDaemon();
55+
} catch (failure) {
56+
setFailed({ title: b.restoreFailed, text: backupError(failure, b.reasons) });
57+
setBusy(false);
58+
}
59+
};
60+
61+
const cancel = async () => {
62+
await api.call("backup.cancel").catch(() => null);
63+
setManifest(null);
64+
setFile(null);
65+
setPassphrase("");
66+
};
67+
68+
const folders = (manifest?.crews ?? []).flatMap((crew) =>
69+
crew.workFolder ? [crew.workFolder] : [],
70+
);
71+
72+
return (
73+
<div className="flex flex-col gap-3">
74+
{!manifest && (
75+
<>
76+
<div className="flex items-center gap-3">
77+
<Button icon={FolderOpen} onClick={pick} disabled={busy}>
78+
{b.pick}
79+
</Button>
80+
{file && (
81+
<span className="min-w-0 truncate font-mono text-muted text-xs" title={file}>
82+
{file}
83+
</span>
84+
)}
85+
</div>
86+
{file && (
87+
<div className="flex items-end gap-3">
88+
<div className="min-w-0 flex-1">
89+
<TextField
90+
type="password"
91+
autoComplete="current-password"
92+
label={b.copyPassphrase}
93+
value={passphrase}
94+
onChange={(event) => setPassphrase(event.target.value)}
95+
/>
96+
</div>
97+
<Button disabled={busy || passphrase.length === 0} onClick={open}>
98+
{busy ? b.opening : b.open}
99+
</Button>
100+
</div>
101+
)}
102+
</>
103+
)}
104+
{manifest && (
105+
<div className="flex flex-col gap-2 rounded-lg border border-line bg-sunken px-3 py-2.5 text-sm">
106+
<p className="font-medium">{b.from(when(manifest.createdAt))}</p>
107+
<ul className="flex flex-col gap-0.5 text-ink-soft">
108+
{manifest.crews.map((crew) => (
109+
<li key={crew.name}>{b.crew(crew.name, crew.bots.length)}</li>
110+
))}
111+
</ul>
112+
{folders.length > 0 && (
113+
<div className="text-muted text-xs">
114+
<p>{b.workFolders}</p>
115+
<ul className="mt-1 font-mono" data-selectable>
116+
{folders.map((folder) => (
117+
<li key={folder} className="break-all">
118+
{folder}
119+
</li>
120+
))}
121+
</ul>
122+
</div>
123+
)}
124+
<p className="text-warn text-xs">{b.replaces}</p>
125+
<div className="mt-1 flex flex-wrap gap-2">
126+
<Button variant="danger" icon={RotateCcw} disabled={busy} onClick={restore}>
127+
{busy ? b.restoring : b.restore}
128+
</Button>
129+
<Button variant="ghost" disabled={busy} onClick={cancel}>
130+
{b.cancel}
131+
</Button>
132+
</div>
133+
</div>
134+
)}
135+
{failed && (
136+
<Callout tone="danger" title={failed.title}>
137+
{failed.text}
138+
</Callout>
139+
)}
140+
</div>
141+
);
142+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Settings, "Backup" (spec 14.2): save everything to one file locked with
2+
// a passphrase, and restore such a file. The daemon makes the copy; the
3+
// owner picks where it goes with the system's Save dialog.
4+
5+
import { Download } from "lucide-react";
6+
import { useState } from "react";
7+
import { useT } from "../../i18n";
8+
import { errorText } from "../../lib/api";
9+
import { fileSize } from "../../lib/format";
10+
import { RpcError } from "../../lib/rpc";
11+
import { useApi, useHost } from "../../store/context";
12+
import { Button } from "../../ui/Button";
13+
import { Callout } from "../../ui/Callout";
14+
import { TextField } from "../../ui/Field";
15+
import { BackupRestore } from "./BackupRestore";
16+
import { Section } from "./settingsParts";
17+
18+
/** A failure as the owner reads it: the daemon's reason, worded, or the error. */
19+
export function backupError(failure: unknown, reasons: Record<string, string>): string {
20+
const reason = failure instanceof RpcError ? failure.reason : undefined;
21+
return (reason && reasons[reason]) || errorText(failure);
22+
}
23+
24+
type Outcome = { saved: string } | { notSaved: true } | { failed: string } | null;
25+
26+
export function BackupSettings() {
27+
const b = useT().account.backup;
28+
return (
29+
<>
30+
<Section title={b.exportTitle}>
31+
<p className="text-ink-soft text-sm leading-relaxed">{b.exportIntro}</p>
32+
<ExportCopy />
33+
</Section>
34+
<Section title={b.importTitle}>
35+
<p className="text-ink-soft text-sm leading-relaxed">{b.importIntro}</p>
36+
<BackupRestore />
37+
</Section>
38+
</>
39+
);
40+
}
41+
42+
function ExportCopy() {
43+
const b = useT().account.backup;
44+
const api = useApi();
45+
const host = useHost();
46+
const [passphrase, setPassphrase] = useState("");
47+
const [repeat, setRepeat] = useState("");
48+
const [busy, setBusy] = useState(false);
49+
const [outcome, setOutcome] = useState<Outcome>(null);
50+
const mismatch = repeat.length > 0 && repeat !== passphrase;
51+
const ready = [...passphrase].length >= 8 && repeat === passphrase;
52+
53+
const save = async () => {
54+
setBusy(true);
55+
setOutcome(null);
56+
try {
57+
const exported = await api.call("backup.export", { passphrase });
58+
const kept = await host.saveFileAs(exported.path);
59+
setOutcome(kept ? { saved: fileSize(exported.size) } : { notSaved: true });
60+
if (kept) {
61+
setPassphrase("");
62+
setRepeat("");
63+
}
64+
} catch (failure) {
65+
setOutcome({ failed: backupError(failure, b.reasons) });
66+
}
67+
setBusy(false);
68+
};
69+
70+
return (
71+
<div className="flex flex-col gap-3">
72+
<div className="grid grid-cols-2 gap-3">
73+
<TextField
74+
type="password"
75+
autoComplete="new-password"
76+
label={b.passphrase}
77+
value={passphrase}
78+
onChange={(event) => setPassphrase(event.target.value)}
79+
/>
80+
<TextField
81+
type="password"
82+
autoComplete="new-password"
83+
label={b.repeat}
84+
value={repeat}
85+
onChange={(event) => setRepeat(event.target.value)}
86+
/>
87+
</div>
88+
<p className={`text-xs ${mismatch ? "text-danger" : "text-muted"}`}>
89+
{mismatch ? b.mismatch : b.passphraseHint}
90+
</p>
91+
<Button icon={Download} className="self-start" disabled={!ready || busy} onClick={save}>
92+
{busy ? b.exporting : b.export}
93+
</Button>
94+
{outcome && "saved" in outcome && (
95+
<p role="status" className="text-ok text-sm">
96+
{b.saved(outcome.saved)}
97+
</p>
98+
)}
99+
{outcome && "notSaved" in outcome && (
100+
<p role="status" className="text-muted text-sm">
101+
{b.notSaved}
102+
</p>
103+
)}
104+
{outcome && "failed" in outcome && (
105+
<Callout tone="danger" title={b.exportFailed}>
106+
{outcome.failed}
107+
</Callout>
108+
)}
109+
</div>
110+
);
111+
}

‎app/src/features/account/SettingsDialog.tsx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
// Settings (spec 15.1), in parts listed on the left: general (what
22
// Botloft does in the background, the language), the chat, notifications,
3-
// the look, what was archived, and the versions for when something needs
4-
// reporting.
3+
// the look, what was archived, backups, and the versions for when
4+
// something needs reporting.
55

66
import {
77
Archive,
88
Bell,
9+
DatabaseBackup,
910
Info,
1011
type LucideIcon,
1112
MessageSquare,
@@ -19,17 +20,19 @@ import { AboutSettings } from "./AboutSettings";
1920
import { AlertsSettings } from "./AlertsSettings";
2021
import { AppearanceSettings } from "./AppearanceSettings";
2122
import { ArchivedSettings } from "./ArchivedSettings";
23+
import { BackupSettings } from "./BackupSettings";
2224
import { ChatSettings } from "./ChatSettings";
2325
import { GeneralSettings } from "./GeneralSettings";
2426

25-
type Page = "general" | "chat" | "alerts" | "appearance" | "archived" | "about";
27+
type Page = "general" | "chat" | "alerts" | "appearance" | "archived" | "backup" | "about";
2628

2729
const PAGES: { id: Page; icon: LucideIcon }[] = [
2830
{ id: "general", icon: SlidersHorizontal },
2931
{ id: "chat", icon: MessageSquare },
3032
{ id: "alerts", icon: Bell },
3133
{ id: "appearance", icon: Palette },
3234
{ id: "archived", icon: Archive },
35+
{ id: "backup", icon: DatabaseBackup },
3336
{ id: "about", icon: Info },
3437
];
3538

@@ -79,6 +82,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) {
7982
{page === "alerts" && <AlertsSettings />}
8083
{page === "appearance" && <AppearanceSettings />}
8184
{page === "archived" && <ArchivedSettings />}
85+
{page === "backup" && <BackupSettings />}
8286
{page === "about" && <AboutSettings />}
8387
</div>
8488
</div>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import { FAKE_PASSPHRASE } from "../../lib/fakeBackup";
5+
import { FakeHost } from "../../lib/fakeHost";
6+
import { crewOpened, renderApp } from "../../test/app";
7+
8+
afterEach(cleanup);
9+
10+
async function openBackup() {
11+
const fake = new FakeBotloft();
12+
const ops = fake.addCrew("Ops");
13+
fake.addBot(ops.id, "Scout");
14+
fake.addBot(ops.id, "Writer");
15+
const host = new FakeHost();
16+
renderApp(fake, host);
17+
await crewOpened("Ops");
18+
fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ }));
19+
fireEvent.click(screen.getByRole("menuitem", { name: "Settings" }));
20+
const dialog = screen.getByRole("dialog", { name: "Settings" });
21+
fireEvent.click(within(dialog).getByRole("tab", { name: "Backup" }));
22+
return { fake, host, dialog };
23+
}
24+
25+
const type = (field: HTMLElement, value: string) => fireEvent.change(field, { target: { value } });
26+
27+
describe("backup", () => {
28+
test("a copy is saved where the owner picks, once both passphrases match", async () => {
29+
const { fake, host, dialog } = await openBackup();
30+
const save = within(dialog).getByRole("button", { name: "Save a copy…" });
31+
expect(save).toHaveProperty("disabled", true);
32+
type(within(dialog).getByLabelText("Passphrase"), "correct horse");
33+
type(within(dialog).getByLabelText("Passphrase again"), "correct hors");
34+
expect(within(dialog).getByText("The two passphrases are not the same.")).toBeDefined();
35+
expect(save).toHaveProperty("disabled", true);
36+
type(within(dialog).getByLabelText("Passphrase again"), "correct horse");
37+
host.saveChosen = true;
38+
fireEvent.click(save);
39+
expect(await within(dialog).findByText(/^Copy saved \(/)).toBeDefined();
40+
expect(host.savedFiles[0]).toMatch(/\.botloft$/);
41+
const call = fake.calls.find((each) => each.method === "backup.export");
42+
expect(call?.params).toEqual({ passphrase: "correct horse" });
43+
});
44+
45+
test("a copy opens with its passphrase, shows what it holds and restores on restart", async () => {
46+
const { fake, host, dialog } = await openBackup();
47+
host.nextFile = "D:\\Copies\\botloft-2026-10-05-1430.botloft";
48+
fireEvent.click(within(dialog).getByRole("button", { name: "Choose a copy…" }));
49+
const field = await within(dialog).findByLabelText("The copy's passphrase");
50+
expect(host.fileFilters[0]).toEqual(["botloft"]);
51+
52+
type(field, "wrong horse");
53+
fireEvent.click(within(dialog).getByRole("button", { name: "Open" }));
54+
expect(
55+
await within(dialog).findByText("The passphrase is wrong, or the file is damaged."),
56+
).toBeDefined();
57+
58+
type(field, FAKE_PASSPHRASE);
59+
fireEvent.click(within(dialog).getByRole("button", { name: "Open" }));
60+
expect(await within(dialog).findByText("Ops: 2 bots")).toBeDefined();
61+
fireEvent.click(within(dialog).getByRole("button", { name: "Restore and restart" }));
62+
await waitFor(() => expect(host.installs).toContain("restart"));
63+
expect(fake.calls.some((each) => each.method === "backup.confirm")).toBe(true);
64+
});
65+
});

0 commit comments

Comments
 (0)