Skip to content

Commit 473f483

Browse files
authored
Merge pull request #201 from httpsphl/feat/backup-screen
feat(app): Backup in Settings, to save a copy and restore one
2 parents 652e540 + f1c4e45 commit 473f483

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)