diff --git a/Cargo.lock b/Cargo.lock index 4bbf4a2d..351186bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,6 +550,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2 0.11.0", "tempfile", "thiserror 2.0.21", ] diff --git a/README.md b/README.md index 8c147a90..0d518568 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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. diff --git a/app/src/features/account/CloudAuto.tsx b/app/src/features/account/CloudAuto.tsx new file mode 100644 index 00000000..b4f39f94 --- /dev/null +++ b/app/src/features/account/CloudAuto.tsx @@ -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("daily"); + const [busy, setBusy] = useState(false); + const [problem, setProblem] = useState(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) => { + 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 ( +
+

{a.title}

+ {!status.available ? ( +

{a.unavailable}

+ ) : status.enabled ? ( + <> +

+ {status.every === "weekly" ? a.onWeekly : a.onDaily} +

+

+ {status.lastOkAt !== undefined ? a.lastCopy(when(status.lastOkAt)) : a.noneYet} + {status.nextAt !== undefined && ` ${a.next(when(status.nextAt))}`} +

+ {status.lastError && ( + + {t.reasons[status.lastError] ?? t.reasons.cloud_error} + + )} +
+