diff --git a/app/src/features/account/BackupRestore.tsx b/app/src/features/account/BackupRestore.tsx new file mode 100644 index 00000000..cb608cb3 --- /dev/null +++ b/app/src/features/account/BackupRestore.tsx @@ -0,0 +1,142 @@ +// Restoring a copy (spec 14.2): the owner picks the file and types its +// passphrase; the daemon opens it and says what it holds; only after the +// owner confirms does Botloft restart and swap it in. + +import { FolderOpen, RotateCcw } from "lucide-react"; +import { useState } from "react"; +import { useT } from "../../i18n"; +import { when } from "../../lib/format"; +import type { BackupManifest } from "../../lib/protocol.gen"; +import { useApi, useHost } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { TextField } from "../../ui/Field"; +import { backupError } from "./BackupSettings"; + +export function BackupRestore() { + const b = useT().account.backup; + const api = useApi(); + const host = useHost(); + const [file, setFile] = useState(null); + const [passphrase, setPassphrase] = useState(""); + const [manifest, setManifest] = useState(null); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState<{ title: string; text: string } | null>(null); + + const pick = async () => { + const picked = await host.pickFile(b.pickTitle, b.kind, ["botloft"]); + if (picked) { + setFile(picked); + setManifest(null); + setFailed(null); + } + }; + + const open = async () => { + if (!file) { + return; + } + setBusy(true); + setFailed(null); + try { + setManifest(await api.call("backup.stage", { path: file, passphrase })); + } catch (failure) { + setFailed({ title: b.openFailed, text: backupError(failure, b.reasons) }); + } + setBusy(false); + }; + + const restore = async () => { + setBusy(true); + setFailed(null); + try { + await api.call("backup.confirm"); + await host.restartDaemon(); + } catch (failure) { + setFailed({ title: b.restoreFailed, text: backupError(failure, b.reasons) }); + setBusy(false); + } + }; + + const cancel = async () => { + await api.call("backup.cancel").catch(() => null); + setManifest(null); + setFile(null); + setPassphrase(""); + }; + + const folders = (manifest?.crews ?? []).flatMap((crew) => + crew.workFolder ? [crew.workFolder] : [], + ); + + return ( +
+ {!manifest && ( + <> +
+ + {file && ( + + {file} + + )} +
+ {file && ( +
+
+ setPassphrase(event.target.value)} + /> +
+ +
+ )} + + )} + {manifest && ( +
+

{b.from(when(manifest.createdAt))}

+
    + {manifest.crews.map((crew) => ( +
  • {b.crew(crew.name, crew.bots.length)}
  • + ))} +
+ {folders.length > 0 && ( +
+

{b.workFolders}

+
    + {folders.map((folder) => ( +
  • + {folder} +
  • + ))} +
+
+ )} +

{b.replaces}

+
+ + +
+
+ )} + {failed && ( + + {failed.text} + + )} +
+ ); +} diff --git a/app/src/features/account/BackupSettings.tsx b/app/src/features/account/BackupSettings.tsx new file mode 100644 index 00000000..0f016c17 --- /dev/null +++ b/app/src/features/account/BackupSettings.tsx @@ -0,0 +1,111 @@ +// Settings, "Backup" (spec 14.2): save everything to one file locked with +// a passphrase, and restore such a file. The daemon makes the copy; the +// owner picks where it goes with the system's Save dialog. + +import { Download } from "lucide-react"; +import { useState } from "react"; +import { useT } from "../../i18n"; +import { errorText } from "../../lib/api"; +import { fileSize } from "../../lib/format"; +import { RpcError } from "../../lib/rpc"; +import { useApi, useHost } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { TextField } from "../../ui/Field"; +import { BackupRestore } from "./BackupRestore"; +import { Section } from "./settingsParts"; + +/** A failure as the owner reads it: the daemon's reason, worded, or the error. */ +export function backupError(failure: unknown, reasons: Record): string { + const reason = failure instanceof RpcError ? failure.reason : undefined; + return (reason && reasons[reason]) || errorText(failure); +} + +type Outcome = { saved: string } | { notSaved: true } | { failed: string } | null; + +export function BackupSettings() { + const b = useT().account.backup; + return ( + <> +
+

{b.exportIntro}

+ +
+
+

{b.importIntro}

+ +
+ + ); +} + +function ExportCopy() { + const b = useT().account.backup; + const api = useApi(); + const host = useHost(); + const [passphrase, setPassphrase] = useState(""); + const [repeat, setRepeat] = useState(""); + const [busy, setBusy] = useState(false); + const [outcome, setOutcome] = useState(null); + const mismatch = repeat.length > 0 && repeat !== passphrase; + const ready = [...passphrase].length >= 8 && repeat === passphrase; + + const save = async () => { + setBusy(true); + setOutcome(null); + try { + const exported = await api.call("backup.export", { passphrase }); + const kept = await host.saveFileAs(exported.path); + setOutcome(kept ? { saved: fileSize(exported.size) } : { notSaved: true }); + if (kept) { + setPassphrase(""); + setRepeat(""); + } + } catch (failure) { + setOutcome({ failed: backupError(failure, b.reasons) }); + } + setBusy(false); + }; + + return ( +
+
+ setPassphrase(event.target.value)} + /> + setRepeat(event.target.value)} + /> +
+

+ {mismatch ? b.mismatch : b.passphraseHint} +

+ + {outcome && "saved" in outcome && ( +

+ {b.saved(outcome.saved)} +

+ )} + {outcome && "notSaved" in outcome && ( +

+ {b.notSaved} +

+ )} + {outcome && "failed" in outcome && ( + + {outcome.failed} + + )} +
+ ); +} diff --git a/app/src/features/account/SettingsDialog.tsx b/app/src/features/account/SettingsDialog.tsx index c5128067..a0d65c2c 100644 --- a/app/src/features/account/SettingsDialog.tsx +++ b/app/src/features/account/SettingsDialog.tsx @@ -1,11 +1,12 @@ // Settings (spec 15.1), in parts listed on the left: general (what // Botloft does in the background, the language), the chat, notifications, -// the look, what was archived, and the versions for when something needs -// reporting. +// the look, what was archived, backups, and the versions for when +// something needs reporting. import { Archive, Bell, + DatabaseBackup, Info, type LucideIcon, MessageSquare, @@ -19,10 +20,11 @@ import { AboutSettings } from "./AboutSettings"; import { AlertsSettings } from "./AlertsSettings"; import { AppearanceSettings } from "./AppearanceSettings"; import { ArchivedSettings } from "./ArchivedSettings"; +import { BackupSettings } from "./BackupSettings"; import { ChatSettings } from "./ChatSettings"; import { GeneralSettings } from "./GeneralSettings"; -type Page = "general" | "chat" | "alerts" | "appearance" | "archived" | "about"; +type Page = "general" | "chat" | "alerts" | "appearance" | "archived" | "backup" | "about"; const PAGES: { id: Page; icon: LucideIcon }[] = [ { id: "general", icon: SlidersHorizontal }, @@ -30,6 +32,7 @@ const PAGES: { id: Page; icon: LucideIcon }[] = [ { id: "alerts", icon: Bell }, { id: "appearance", icon: Palette }, { id: "archived", icon: Archive }, + { id: "backup", icon: DatabaseBackup }, { id: "about", icon: Info }, ]; @@ -79,6 +82,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) { {page === "alerts" && } {page === "appearance" && } {page === "archived" && } + {page === "backup" && } {page === "about" && } diff --git a/app/src/features/account/backup.test.tsx b/app/src/features/account/backup.test.tsx new file mode 100644 index 00000000..b833311f --- /dev/null +++ b/app/src/features/account/backup.test.tsx @@ -0,0 +1,65 @@ +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { FAKE_PASSPHRASE } from "../../lib/fakeBackup"; +import { FakeHost } from "../../lib/fakeHost"; +import { crewOpened, renderApp } from "../../test/app"; + +afterEach(cleanup); + +async function openBackup() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + fake.addBot(ops.id, "Scout"); + fake.addBot(ops.id, "Writer"); + const host = new FakeHost(); + renderApp(fake, host); + 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" })); + return { fake, host, dialog }; +} + +const type = (field: HTMLElement, value: string) => fireEvent.change(field, { target: { value } }); + +describe("backup", () => { + test("a copy is saved where the owner picks, once both passphrases match", async () => { + const { fake, host, dialog } = await openBackup(); + const save = within(dialog).getByRole("button", { name: "Save a copy…" }); + expect(save).toHaveProperty("disabled", true); + type(within(dialog).getByLabelText("Passphrase"), "correct horse"); + type(within(dialog).getByLabelText("Passphrase again"), "correct hors"); + expect(within(dialog).getByText("The two passphrases are not the same.")).toBeDefined(); + expect(save).toHaveProperty("disabled", true); + type(within(dialog).getByLabelText("Passphrase again"), "correct horse"); + host.saveChosen = true; + fireEvent.click(save); + expect(await within(dialog).findByText(/^Copy saved \(/)).toBeDefined(); + expect(host.savedFiles[0]).toMatch(/\.botloft$/); + const call = fake.calls.find((each) => each.method === "backup.export"); + expect(call?.params).toEqual({ passphrase: "correct horse" }); + }); + + test("a copy opens with its passphrase, shows what it holds and restores on restart", async () => { + const { fake, host, dialog } = await openBackup(); + host.nextFile = "D:\\Copies\\botloft-2026-10-05-1430.botloft"; + fireEvent.click(within(dialog).getByRole("button", { name: "Choose a copy…" })); + const field = await within(dialog).findByLabelText("The copy's passphrase"); + expect(host.fileFilters[0]).toEqual(["botloft"]); + + type(field, "wrong horse"); + fireEvent.click(within(dialog).getByRole("button", { name: "Open" })); + expect( + await within(dialog).findByText("The passphrase is wrong, or the file is damaged."), + ).toBeDefined(); + + type(field, FAKE_PASSPHRASE); + fireEvent.click(within(dialog).getByRole("button", { name: "Open" })); + expect(await within(dialog).findByText("Ops: 2 bots")).toBeDefined(); + fireEvent.click(within(dialog).getByRole("button", { name: "Restore and restart" })); + await waitFor(() => expect(host.installs).toContain("restart")); + expect(fake.calls.some((each) => each.method === "backup.confirm")).toBe(true); + }); +}); diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index 2f9ab6a9..696726c9 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -54,6 +54,47 @@ export const account = { "Each bot's share of your weekly plan shows up once the plan has gone up a few points while the bots work. Until then, the tokens.", }, }, + backup: { + exportTitle: "Save a copy", + exportIntro: + "Keeps your crews, bots, chats and each bot's memory in one file locked with a passphrase, to bring back after a new install or on another computer. Work folders you chose outside Botloft are not in it.", + passphrase: "Passphrase", + passphraseHint: + "At least 8 characters. Without it the copy cannot be opened, and Botloft does not keep it anywhere.", + repeat: "Passphrase again", + mismatch: "The two passphrases are not the same.", + export: "Save a copy…", + exporting: "Making the copy…", + saved: (size: string) => `Copy saved (${size}).`, + notSaved: "The copy was not saved.", + exportFailed: "Could not make the copy", + importTitle: "Restore a copy", + importIntro: + "Replaces everything in this Botloft with a copy. What is here now goes to a folder aside: nothing is deleted.", + pick: "Choose a copy…", + pickTitle: "Choose a Botloft copy", + kind: "Botloft copy", + copyPassphrase: "The copy's passphrase", + open: "Open", + opening: "Opening…", + from: (when: string) => `Copy from ${when}`, + crew: (name: string, bots: number) => + `${name}: ${bots === 0 ? "no bots" : bots === 1 ? "1 bot" : `${bots} bots`}`, + workFolders: + "These work folders are not in the copy. Put their files back yourself if this is another computer:", + replaces: "Everything in this Botloft is replaced. Botloft restarts to do it.", + restore: "Restore and restart", + restoring: "Restarting…", + cancel: "Cancel", + openFailed: "Could not open the copy", + restoreFailed: "Could not restore the copy", + reasons: { + short_passphrase: "The passphrase needs at least 8 characters.", + wrong_passphrase: "The passphrase is wrong, or the file is damaged.", + not_a_backup: "This file is not a Botloft copy.", + newer_backup: "This copy is from a newer Botloft. Update Botloft first.", + } as Record, + }, settings: { title: "Settings", background: "In the background", @@ -131,6 +172,7 @@ export const account = { deleteArchived: "Delete", deleteArchivedOne: (name: string) => `Delete ${name}`, about: "About", + backup: "Backup", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, checkUpdates: "Check for updates", diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index 0f33bafc..fc297414 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -54,6 +54,47 @@ export const account: Messages["account"] = { "La parte de cada bot en tu plan semanal aparece cuando el plan haya subido unos puntos con los bots trabajando. Hasta entonces, los tokens.", }, }, + backup: { + exportTitle: "Guardar una copia", + exportIntro: + "Guarda tus equipos, bots, conversaciones y la memoria de cada bot en un archivo cerrado con una contraseña, para recuperarlo tras reinstalar o en otra computadora. Las carpetas de trabajo que elegiste fuera de Botloft no entran.", + passphrase: "Contraseña", + passphraseHint: + "Al menos 8 caracteres. Sin ella la copia no se abre, y Botloft no la guarda en ningún lado.", + repeat: "Contraseña otra vez", + mismatch: "Las dos contraseñas no son iguales.", + export: "Guardar una copia…", + exporting: "Haciendo la copia…", + saved: (size) => `Copia guardada (${size}).`, + notSaved: "La copia no se guardó.", + exportFailed: "No se pudo hacer la copia", + importTitle: "Restaurar una copia", + importIntro: + "Cambia todo lo que hay en este Botloft por una copia. Lo que hay ahora va a una carpeta aparte: nada se borra.", + pick: "Elegir una copia…", + pickTitle: "Elige una copia de Botloft", + kind: "Copia de Botloft", + copyPassphrase: "Contraseña de la copia", + open: "Abrir", + opening: "Abriendo…", + from: (when) => `Copia del ${when}`, + crew: (name, bots) => + `${name}: ${bots === 0 ? "ningún bot" : bots === 1 ? "1 bot" : `${bots} bots`}`, + workFolders: + "Estas carpetas de trabajo no están en la copia. Si es otra computadora, vuelve a poner sus archivos:", + replaces: "Se cambia todo en este Botloft. Botloft se reinicia para hacerlo.", + restore: "Restaurar y reiniciar", + restoring: "Reiniciando…", + cancel: "Cancelar", + openFailed: "No se pudo abrir la copia", + restoreFailed: "No se pudo restaurar la copia", + reasons: { + short_passphrase: "La contraseña necesita al menos 8 caracteres.", + wrong_passphrase: "La contraseña es incorrecta, o el archivo está dañado.", + not_a_backup: "Este archivo no es una copia de Botloft.", + newer_backup: "Esta copia es de un Botloft más nuevo. Actualiza Botloft antes.", + } as Record, + }, settings: { title: "Configuración", background: "En segundo plano", @@ -130,6 +171,7 @@ export const account: Messages["account"] = { deleteArchived: "Eliminar", deleteArchivedOne: (name: string) => `Eliminar ${name}`, about: "Acerca de", + backup: "Copia de seguridad", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, checkUpdates: "Buscar actualizaciones", diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index 0740017b..d97a856b 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -54,6 +54,47 @@ export const account: Messages["account"] = { "A parte de cada bot no seu plano semanal aparece depois que o plano subir alguns pontos com os bots trabalhando. Até lá, os tokens.", }, }, + backup: { + exportTitle: "Salvar uma cópia", + exportIntro: + "Guarda suas equipes, bots, conversas e a memória de cada bot num arquivo trancado com uma senha, para trazer de volta depois de reinstalar ou em outro computador. Pastas de trabalho que você escolheu fora do Botloft não entram.", + passphrase: "Senha", + passphraseHint: + "Ao menos 8 caracteres. Sem ela a cópia não abre, e o Botloft não a guarda em lugar nenhum.", + repeat: "Senha de novo", + mismatch: "As duas senhas não são iguais.", + export: "Salvar uma cópia…", + exporting: "Fazendo a cópia…", + saved: (size) => `Cópia salva (${size}).`, + notSaved: "A cópia não foi salva.", + exportFailed: "Não foi possível fazer a cópia", + importTitle: "Restaurar uma cópia", + importIntro: + "Troca tudo o que está neste Botloft por uma cópia. O que existe agora vai para uma pasta à parte: nada é apagado.", + pick: "Escolher uma cópia…", + pickTitle: "Escolha uma cópia do Botloft", + kind: "Cópia do Botloft", + copyPassphrase: "Senha da cópia", + open: "Abrir", + opening: "Abrindo…", + from: (when) => `Cópia de ${when}`, + crew: (name, bots) => + `${name}: ${bots === 0 ? "nenhum bot" : bots === 1 ? "1 bot" : `${bots} bots`}`, + workFolders: + "Estas pastas de trabalho não estão na cópia. Se este é outro computador, ponha os arquivos delas de volta:", + replaces: "Tudo neste Botloft é trocado. O Botloft reinicia para isso.", + restore: "Restaurar e reiniciar", + restoring: "Reiniciando…", + cancel: "Cancelar", + openFailed: "Não foi possível abrir a cópia", + restoreFailed: "Não foi possível restaurar a cópia", + reasons: { + short_passphrase: "A senha precisa de ao menos 8 caracteres.", + wrong_passphrase: "A senha está errada, ou o arquivo está danificado.", + not_a_backup: "Este arquivo não é uma cópia do Botloft.", + newer_backup: "Esta cópia é de um Botloft mais novo. Atualize o Botloft antes.", + } as Record, + }, settings: { title: "Configurações", background: "Em segundo plano", @@ -130,6 +171,7 @@ export const account: Messages["account"] = { deleteArchived: "Excluir", deleteArchivedOne: (name: string) => `Excluir ${name}`, about: "Sobre", + backup: "Cópia de segurança", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, checkUpdates: "Procurar atualizações", diff --git a/app/src/lib/fakeBackup.ts b/app/src/lib/fakeBackup.ts index 81ec2ea3..a415784f 100644 --- a/app/src/lib/fakeBackup.ts +++ b/app/src/lib/fakeBackup.ts @@ -1,39 +1,54 @@ // The fake daemon's backups (spec 14.2): what an export would hold, from -// the fake crews, at a made-up path. Nothing is sealed or written. +// the fake crews, at a made-up path, and a backup that opens only with +// `FAKE_PASSPHRASE`. Nothing is sealed, written or restored. import type { FakeBotloft, Handlers } from "./fake"; -import { RpcErrorCode } from "./protocol.gen"; +import { type BackupManifest, RpcErrorCode } from "./protocol.gen"; import { RpcError } from "./rpc"; type BackupMethods = Extract; +/** The passphrase the fake's backups open with. */ +export const FAKE_PASSPHRASE = "correct horse"; + +function refused(reason: string, message: string): RpcError { + return new RpcError(RpcErrorCode.validation, message, reason); +} + +function manifestOf(fake: FakeBotloft): BackupManifest { + return { + format: 1, + createdAt: fake.now, + version: "0.10.0", + crews: [...fake.crews.values()].map((crew) => ({ + name: crew.name, + bots: [...fake.bots.values()] + .filter((bot) => bot.crewId === crew.id && bot.archivedAt === null) + .map((bot) => bot.name), + workFolder: crew.workFolderChosen ? crew.workFolder : null, + })), + }; +} + export function backupHandlers(fake: FakeBotloft): Pick { return { "backup.export": ({ passphrase }) => { if ([...passphrase].length < 8) { - throw new RpcError( - RpcErrorCode.validation, - "the passphrase must have at least 8 characters", - "short_passphrase", - ); + throw refused("short_passphrase", "the passphrase must have at least 8 characters"); } - const crews = [...fake.crews.values()]; return { path: "C:\\Users\\owner\\AppData\\Local\\Botloft\\exports\\botloft-2026-10-05-1430.botloft", size: 1_234_567, - manifest: { - format: 1, - createdAt: fake.now, - version: "0.10.0", - crews: crews.map((crew) => ({ - name: crew.name, - bots: [...fake.bots.values()] - .filter((bot) => bot.crewId === crew.id && bot.archivedAt === null) - .map((bot) => bot.name), - workFolder: crew.workFolderChosen ? crew.workFolder : null, - })), - }, + manifest: manifestOf(fake), }; }, + "backup.stage": ({ passphrase }) => { + if (passphrase !== FAKE_PASSPHRASE) { + throw refused("wrong_passphrase", "the passphrase is wrong, or the file is damaged"); + } + return manifestOf(fake); + }, + "backup.confirm": () => null, + "backup.cancel": () => null, }; } diff --git a/app/src/lib/fakeHost.ts b/app/src/lib/fakeHost.ts index 70dc0ab5..d8dbe3a6 100644 --- a/app/src/lib/fakeHost.ts +++ b/app/src/lib/fakeHost.ts @@ -33,6 +33,9 @@ export class FakeHost implements Host { nextFolder: string | null = null; /** Where each folder picker started. */ readonly pickerStarts: (string | null)[] = []; + /** What `pickFile` gives next; `null`, the owner cancels. */ + nextFile: string | null = null; + readonly fileFilters: string[][] = []; readonly installs: ("install" | "restart")[] = []; /** How many times the app stopped the daemon. */ stops = 0; @@ -182,6 +185,11 @@ export class FakeHost implements Host { return Promise.resolve(this.nextFolder); } + pickFile(_title: string, _kind: string, extensions: string[]): Promise { + this.fileFilters.push(extensions); + return Promise.resolve(this.nextFile); + } + openUrl(url: string): Promise { this.opened.push(url); return Promise.resolve(); diff --git a/app/src/lib/host.ts b/app/src/lib/host.ts index ec0c6e41..1e4f6458 100644 --- a/app/src/lib/host.ts +++ b/app/src/lib/host.ts @@ -129,6 +129,11 @@ export interface Host { * `start` if given. Resolves null if they cancel. */ pickFolder(title: string, start?: string): Promise; + /** + * Asks the owner for one file with the system's Open dialog, showing + * files with `extensions` under `kind`. Resolves null if they cancel. + */ + pickFile(title: string, kind: string, extensions: string[]): Promise; /** Opens an http(s) link in the default browser. */ openUrl(url: string): Promise; window: AppWindow; diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 4a584c48..793b7949 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -1079,6 +1079,11 @@ export type BackupExported = { path: string, */ size: number, manifest: BackupManifest, }; +/** + * `backup.stage`: a backup file the owner picked, and its passphrase. + */ +export type BackupStageParams = { path: string, passphrase: string, }; + export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, /** * What the bot says the command is for, as in `ToolItem`. The bot @@ -1778,6 +1783,9 @@ export interface RpcMethods { "crewAccess.list": { params: CrewAccessListParams; result: Array }; "crewAccess.revoke": { params: CrewAccessIdParams; result: Array }; "backup.export": { params: BackupExportParams; result: BackupExported }; + "backup.stage": { params: BackupStageParams; result: BackupManifest }; + "backup.confirm": { params: undefined; result: null }; + "backup.cancel": { params: undefined; result: null }; "routines.list": { params: RoutinesListParams; result: Array }; "routines.create": { params: RoutinesCreateParams; result: Routine }; "routines.update": { params: RoutinesUpdateParams; result: Routine }; diff --git a/app/src/lib/tauriHost.ts b/app/src/lib/tauriHost.ts index 1149ac44..eea960d7 100644 --- a/app/src/lib/tauriHost.ts +++ b/app/src/lib/tauriHost.ts @@ -142,6 +142,15 @@ export function tauriHost(): Host { }); return typeof picked === "string" ? picked : null; }, + pickFile: async (title, kind, extensions) => { + const picked = await open({ + directory: false, + multiple: false, + title, + filters: [{ name: kind, extensions }], + }); + return typeof picked === "string" ? picked : null; + }, openUrl: (url) => invoke("open_url", { url }), window: { minimize: () => window.minimize(), diff --git a/crates/botloft-core/src/protocol/backup.rs b/crates/botloft-core/src/protocol/backup.rs index 11d6bd59..a2d685ce 100644 --- a/crates/botloft-core/src/protocol/backup.rs +++ b/crates/botloft-core/src/protocol/backup.rs @@ -35,6 +35,15 @@ pub struct BackupExportParams { pub passphrase: String, } +/// `backup.stage`: a backup file the owner picked, and its passphrase. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BackupStageParams { + pub path: String, + pub passphrase: String, +} + /// A backup written and ready for the owner to save elsewhere. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/botloft-core/src/protocol/export_backup.rs b/crates/botloft-core/src/protocol/export_backup.rs index e6103bc7..009520b2 100644 --- a/crates/botloft-core/src/protocol/export_backup.rs +++ b/crates/botloft-core/src/protocol/export_backup.rs @@ -8,6 +8,7 @@ pub(super) fn decls(out: &mut Out) { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); } pub(super) fn methods(out: &mut Out) { @@ -16,4 +17,11 @@ pub(super) fn methods(out: &mut Out) { &out.name::(), &out.name::(), ); + out.method( + method::BACKUP_STAGE, + &out.name::(), + &out.name::(), + ); + out.method(method::BACKUP_CONFIRM, "undefined", "null"); + out.method(method::BACKUP_CANCEL, "undefined", "null"); } diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 05b0e970..becfa437 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -129,6 +129,9 @@ pub mod method { pub const RULES_LIST: &str = "rules.list"; pub const RULES_DELETE: &str = "rules.delete"; pub const BACKUP_EXPORT: &str = "backup.export"; + pub const BACKUP_STAGE: &str = "backup.stage"; + pub const BACKUP_CONFIRM: &str = "backup.confirm"; + pub const BACKUP_CANCEL: &str = "backup.cancel"; pub const CREW_ACCESS_LIST: &str = "crewAccess.list"; pub const CREW_ACCESS_REVOKE: &str = "crewAccess.revoke"; pub const DESKTOP_GRANTS: &str = "desktop.grants"; diff --git a/crates/botloftd/src/backup/mod.rs b/crates/botloftd/src/backup/mod.rs index 22f43e56..0818da0b 100644 --- a/crates/botloftd/src/backup/mod.rs +++ b/crates/botloftd/src/backup/mod.rs @@ -2,6 +2,7 @@ //! crew's folder, in one file sealed with a passphrase only the owner has. pub mod export; +pub mod restore; pub mod seal; /// The extension of a backup file. diff --git a/crates/botloftd/src/backup/restore.rs b/crates/botloftd/src/backup/restore.rs new file mode 100644 index 00000000..2d8ae2bc --- /dev/null +++ b/crates/botloftd/src/backup/restore.rs @@ -0,0 +1,198 @@ +//! Importing a backup (spec 14.2). The file is opened and staged while +//! Botloft runs, and only swapped in on the next start, before the +//! database opens: nothing has the old data open then. What was there goes +//! aside, never deleted. + +use std::fs::{self, File}; +use std::io::{self, BufReader, BufWriter, Read}; +use std::path::{Path, PathBuf}; + +use botloft_core::protocol::BackupManifest; +use tracing::{info, warn}; +use zip::ZipArchive; + +use super::seal::{SealError, open}; +use super::{DATABASE_ENTRY, MANIFEST_ENTRY, WORKSPACES_ENTRY}; +use crate::paths::Paths; + +const STAGED: &str = "staged.zip"; +/// Written when the owner confirms: the next start applies the backup. +const READY: &str = "READY"; +/// What the archive unpacks into before the swap. +const UNPACKED: &str = "unpacked"; + +fn restore_dir(paths: &Paths) -> PathBuf { + paths.home.join("restore") +} + +fn other(err: impl std::fmt::Display) -> io::Error { + io::Error::other(err.to_string()) +} + +/// "0.10.0" as numbers, to compare. +fn version(text: &str) -> Vec { + text.split('.') + .map(|part| part.trim().parse().unwrap_or(0)) + .collect() +} + +/// Opens the backup with the passphrase, checks it, and keeps it staged. +/// Nothing changes until `confirm` and a restart. +pub fn stage(paths: &Paths, file: &Path, passphrase: &str) -> Result { + let dir = restore_dir(paths); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir)?; + let staged = dir.join(STAGED); + let opened = { + let mut sealed = BufReader::new(File::open(file)?); + let mut out = BufWriter::new(File::create(&staged)?); + open(&mut sealed, &mut out, passphrase) + }; + if let Err(err) = opened { + let _ = fs::remove_dir_all(&dir); + return Err(err); + } + let checked = check(&staged); + if checked.is_err() { + let _ = fs::remove_dir_all(&dir); + } + checked +} + +fn check(staged: &Path) -> Result { + let mut zip = ZipArchive::new(File::open(staged)?).map_err(|_| SealError::NotABackup)?; + let mut text = String::new(); + zip.by_name(MANIFEST_ENTRY) + .map_err(|_| SealError::NotABackup)? + .read_to_string(&mut text)?; + let manifest: BackupManifest = + serde_json::from_str(&text).map_err(|_| SealError::NotABackup)?; + if manifest.format != 1 || version(&manifest.version) > version(env!("CARGO_PKG_VERSION")) { + return Err(SealError::Newer); + } + zip.by_name(DATABASE_ENTRY) + .map_err(|_| SealError::NotABackup)?; + Ok(manifest) +} + +/// The owner said yes: the staged backup replaces everything on the next +/// start. +pub fn confirm(paths: &Paths) -> io::Result<()> { + let dir = restore_dir(paths); + if !dir.join(STAGED).is_file() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "no backup is ready to restore; open it again", + )); + } + fs::write(dir.join(READY), b"") +} + +/// The owner changed their mind: the staged backup goes. +pub fn cancel(paths: &Paths) { + let _ = fs::remove_dir_all(restore_dir(paths)); +} + +/// On start, before the database opens: swaps in a confirmed backup. The +/// database and the crews' folders that were there move aside, to +/// `\before-restore\