- create_backup writes a zip (manifest with sha256 per file, a VACUUM INTO database snapshot, assets, archive, fonts) atomically. restore_backup validates the manifest, rejects tampered files, path traversal, symlinks and newer schemas, checks the staged DB with integrity_check, then stages a pending restore. The swap runs on the next start before the database opens, moves the current data to backups/pre-restore-<ts>/ and rolls back on any failure. - Daily automatic backup (migration M9 auto_backup, default on) keeps the newest 14 and never blocks startup. - Settings Data tab: back up now, restore with confirmation and restart, auto-backup toggle, last backup time. - History exports the filtered rows as CSV (UTF-8 BOM, CRLF, formula-injection guard) or JSON (voiced.history.v1) with CA-friendly tax columns from a new list_invoice_ledger command. - Rust round-trip, rejection, swap-rollback and retention tests; vitest for the export and backup helpers. Adds the zip 8.6.0 and typed-path 0.12.3 crates (pure Rust); package builds must vendor them. PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
|
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
|
import { api } from "../lib/api";
|
|
import {
|
|
backupFileName,
|
|
ensureZipExtension,
|
|
formatBytes,
|
|
formatWhen,
|
|
type BackupStatus,
|
|
type PendingRestore,
|
|
} from "../lib/backup";
|
|
import { useReturnFocus } from "../hooks/useReturnFocus";
|
|
import { useToast } from "./ToastProvider";
|
|
|
|
const ZIP_FILTER = [{ name: "Voiced backup", extensions: ["zip"] }];
|
|
|
|
type RestoreStep = "confirm" | "staging" | "ready";
|
|
|
|
const joinPath = (dir: string, name: string) =>
|
|
!dir ? name : /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${dir.includes("\\") && !dir.includes("/") ? "\\" : "/"}${name}`;
|
|
|
|
/** Settings > Data: manual backup, restore (staged, applied after a restart) and the automatic daily backup. */
|
|
export default function DataBackupPanel() {
|
|
const toast = useToast();
|
|
const [status, setStatus] = useState<BackupStatus | null>(null);
|
|
const [backingUp, setBackingUp] = useState(false);
|
|
const [restorePath, setRestorePath] = useState<string | null>(null);
|
|
const [step, setStep] = useState<RestoreStep>("confirm");
|
|
const [staged, setStaged] = useState<PendingRestore | null>(null);
|
|
const [restoreError, setRestoreError] = useState<string | null>(null);
|
|
const [restarting, setRestarting] = useState(false);
|
|
useReturnFocus(restorePath !== null);
|
|
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
setStatus(await api.getBackupStatus());
|
|
} catch (e) {
|
|
toast.error("Could not load backup status", String(e));
|
|
}
|
|
}, [toast]);
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
}, [refresh]);
|
|
|
|
const backUpNow = async () => {
|
|
setBackingUp(true);
|
|
try {
|
|
let dir = "";
|
|
try {
|
|
dir = await api.getLastExportDir();
|
|
} catch {
|
|
// No remembered folder: the dialog opens wherever the system prefers.
|
|
}
|
|
const chosen = await save({ defaultPath: joinPath(dir, backupFileName()), filters: ZIP_FILTER });
|
|
if (!chosen) return;
|
|
const summary = await api.createBackup(ensureZipExtension(chosen));
|
|
toast.success("Backup saved", `${summary.fileCount} files, ${formatBytes(summary.totalBytes)}: ${summary.path}`);
|
|
} catch (e) {
|
|
toast.error("Backup failed", String(e));
|
|
} finally {
|
|
setBackingUp(false);
|
|
}
|
|
};
|
|
|
|
const chooseRestoreFile = async () => {
|
|
try {
|
|
const picked = await open({ multiple: false, directory: false, filters: ZIP_FILTER });
|
|
if (typeof picked !== "string") return;
|
|
setRestoreError(null);
|
|
setStaged(null);
|
|
setStep("confirm");
|
|
setRestorePath(picked);
|
|
} catch (e) {
|
|
toast.error("Could not open the file picker", String(e));
|
|
}
|
|
};
|
|
|
|
const closeRestore = () => {
|
|
if (step === "staging") return;
|
|
setRestorePath(null);
|
|
void refresh();
|
|
};
|
|
|
|
const stageRestore = async () => {
|
|
if (!restorePath) return;
|
|
setStep("staging");
|
|
setRestoreError(null);
|
|
try {
|
|
setStaged(await api.restoreBackup(restorePath));
|
|
setStep("ready");
|
|
} catch (e) {
|
|
setRestoreError(String(e));
|
|
setStep("confirm");
|
|
toast.error("This backup cannot be restored", String(e));
|
|
}
|
|
};
|
|
|
|
const restart = async () => {
|
|
setRestarting(true);
|
|
try {
|
|
await api.restartApp();
|
|
} catch (e) {
|
|
setRestarting(false);
|
|
toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the restore.`);
|
|
}
|
|
};
|
|
|
|
const cancelPending = async () => {
|
|
try {
|
|
await api.cancelPendingRestore();
|
|
toast.info("Restore cancelled", "Your data was not changed.");
|
|
} catch (e) {
|
|
toast.error("Could not cancel the restore", String(e));
|
|
}
|
|
setRestorePath(null);
|
|
await refresh();
|
|
};
|
|
|
|
const toggleAuto = async (enabled: boolean) => {
|
|
try {
|
|
const saved = await api.setAutoBackup(enabled);
|
|
setStatus((prev) => (prev ? { ...prev, autoBackup: saved } : prev));
|
|
toast.success(saved ? "Automatic backup on" : "Automatic backup off");
|
|
} catch (e) {
|
|
toast.error("Could not change automatic backup", String(e));
|
|
await refresh();
|
|
}
|
|
};
|
|
|
|
const openFolder = async (path: string) => {
|
|
try {
|
|
await api.openFile(path);
|
|
} catch (e) {
|
|
toast.error("Could not open the folder", String(e));
|
|
}
|
|
};
|
|
|
|
const pending = status?.pendingRestore ?? null;
|
|
|
|
return (
|
|
<section className="voiced-stack">
|
|
{pending ? (
|
|
<InlineNotification
|
|
kind="warning"
|
|
lowContrast
|
|
hideCloseButton
|
|
title="A restore is waiting for a restart"
|
|
subtitle={`${pending.backupName} (${pending.fileCount} files, ${formatBytes(pending.totalBytes)}) has been checked. Restart Voiced to switch to it; your current data is kept in a safety copy.`}
|
|
/>
|
|
) : null}
|
|
{pending ? (
|
|
<div className="voiced-actions">
|
|
<Button size="md" onClick={() => void restart()} disabled={restarting}>
|
|
{restarting ? "Restarting…" : "Restart now"}
|
|
</Button>
|
|
<Button size="md" kind="tertiary" onClick={() => void cancelPending()} disabled={restarting}>
|
|
Cancel restore
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
<Tile>
|
|
<h2 className="voiced-section-title">Back up</h2>
|
|
<p className="voiced-muted">
|
|
Saves one zip with your invoices, clients, payments, settings, logo and signature images, archived PDFs and imported
|
|
fonts. Keep a copy somewhere other than this computer.
|
|
</p>
|
|
<div className="voiced-actions">
|
|
<Button size="md" onClick={() => void backUpNow()} disabled={backingUp}>
|
|
{backingUp ? "Backing up…" : "Back up now"}
|
|
</Button>
|
|
{backingUp ? <InlineLoading description="Writing the backup" /> : null}
|
|
</div>
|
|
</Tile>
|
|
|
|
<Tile>
|
|
<h2 className="voiced-section-title">Restore from backup</h2>
|
|
<p className="voiced-muted">
|
|
Replaces everything in Voiced with the contents of a backup file. Your current data is moved into a safety copy first,
|
|
never deleted.
|
|
</p>
|
|
<div className="voiced-actions">
|
|
<Button size="md" kind="danger--tertiary" onClick={() => void chooseRestoreFile()} disabled={restorePath !== null || !!pending}>
|
|
Restore from backup…
|
|
</Button>
|
|
</div>
|
|
{status?.lastRestore ? (
|
|
<p className="voiced-muted" role="status">
|
|
{status.lastRestore.ok
|
|
? `Last restore: ${status.lastRestore.backupName || "a backup"}, ${formatWhen(status.lastRestore.at)}.`
|
|
: `The last restore attempt (${formatWhen(status.lastRestore.at)}) did not go through: ${status.lastRestore.message}`}{" "}
|
|
{status.lastRestore.safetyDir ? (
|
|
<Button kind="ghost" size="sm" onClick={() => void api.revealInFolder(status.lastRestore!.safetyDir!).catch((e) => toast.error("Could not open the folder", String(e)))}>
|
|
Show safety copy
|
|
</Button>
|
|
) : null}
|
|
</p>
|
|
) : null}
|
|
</Tile>
|
|
|
|
<Tile>
|
|
<h2 className="voiced-section-title">Automatic backup</h2>
|
|
<Toggle
|
|
id="autoBackup"
|
|
labelText="Back up automatically every day"
|
|
labelA="Off"
|
|
labelB="On"
|
|
toggled={status?.autoBackup ?? true}
|
|
disabled={!status}
|
|
onToggle={(checked: boolean) => void toggleAuto(checked)}
|
|
/>
|
|
<p className="voiced-muted">
|
|
While Voiced is open it writes one backup a day into its data folder and keeps the newest 14. This changes right away; you
|
|
do not need to click Save settings.
|
|
</p>
|
|
<p role="status">
|
|
{status === null
|
|
? "Loading…"
|
|
: status.lastAutoBackup
|
|
? `Last automatic backup: ${formatWhen(status.lastAutoBackup)} (${status.autoBackupCount} kept).`
|
|
: "No automatic backup yet."}
|
|
</p>
|
|
<div className="voiced-actions">
|
|
<Button size="md" kind="tertiary" disabled={!status} onClick={() => status && void openFolder(status.autoDir)}>
|
|
Open backup folder
|
|
</Button>
|
|
</div>
|
|
</Tile>
|
|
|
|
<Modal
|
|
open={restorePath !== null}
|
|
danger={step === "confirm"}
|
|
modalHeading={step === "ready" ? "Restart to finish restoring" : "Restore from this backup?"}
|
|
primaryButtonText={
|
|
step === "confirm" ? "Check and restore" : step === "staging" ? "Checking the backup…" : restarting ? "Restarting…" : "Restart now"
|
|
}
|
|
secondaryButtonText={step === "ready" ? "Later" : "Cancel"}
|
|
primaryButtonDisabled={step === "staging" || restarting}
|
|
onRequestClose={closeRestore}
|
|
onRequestSubmit={() => void (step === "confirm" ? stageRestore() : step === "ready" ? restart() : undefined)}
|
|
onSecondarySubmit={closeRestore}
|
|
>
|
|
<div className="voiced-stack">
|
|
{restoreError ? <InlineNotification kind="error" lowContrast hideCloseButton title="Cannot restore" subtitle={restoreError} /> : null}
|
|
{step === "ready" && staged ? (
|
|
<>
|
|
<p>
|
|
<strong>{staged.backupName}</strong> passed every check: {staged.fileCount} files, {formatBytes(staged.totalBytes)}, made{" "}
|
|
{formatWhen(staged.backupCreatedAt)}.
|
|
</p>
|
|
<p>
|
|
Voiced must restart to switch to it. Until then nothing has changed. Choose Later to keep working and restart from this tab
|
|
when you are ready (anything you add before restarting will not be in the restored data, but stays in the safety copy).
|
|
</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<p>
|
|
File: <strong>{restorePath}</strong>
|
|
</p>
|
|
<p>
|
|
Everything now in Voiced (invoices, clients, payments, settings, images, archived PDFs and fonts) will be replaced by the
|
|
contents of this backup.
|
|
</p>
|
|
<ul className="voiced-list">
|
|
<li>The file is checked first; a damaged or newer-version backup is refused and nothing changes.</li>
|
|
<li>Your current data is moved to a safety copy in Voiced’s backups folder before the swap. It is never deleted.</li>
|
|
<li>Voiced restarts to complete the restore.</li>
|
|
</ul>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
</section>
|
|
);
|
|
}
|