Author SHA1 Message Date
xavierkandClaude Sonnet 5.5 8122129a19 Add Reset all data to Settings > Data and release 1.2.0
Windows build / build (push) Waiting to run
CI / security (pull_request) Successful in 40s
CI / lint (pull_request) Successful in 2m50s
CI / e2e (pull_request) Successful in 3m3s
CI / ai-review (pull_request) Failing after 10m2s
Reset all data stages a wipe behind a typed RESET confirmation and applies it
on the next start, before the database is opened. It deletes the database,
assets, PDF archive, imported fonts and every backup, then the setup wizard
shows again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:29:52 +05:30
15 changed files with 405 additions and 21 deletions
+15 -4
View File
@@ -8,7 +8,7 @@ connection. The only optional network feature is the ERPNext push, which you con
Targets: Void Linux (xbps, WebKitGTK 2.50 or newer) and Windows 10/11 (NSIS installer, WebView2).
Current release: **1.1.0**. Installers and the Void package are attached to the release on the project's Gitea
Current release: **1.2.0**. Installers and the Void package are attached to the release on the project's Gitea
page (see "Installing").
## Features
@@ -38,6 +38,7 @@ page (see "Installing").
- **Payments.** Record payments against an issued invoice, including TDS deducted by the client. Outstanding
balance is cash plus TDS against the invoice total.
- **History.** Filterable invoice list, detail view with payments, and CSV or JSON export of the list.
- **Reset all data.** Settings, Data wipes everything and returns to first-run setup after a typed `RESET`.
- **Backup and restore.** Manual and automatic daily backups of the database, assets, PDF archive and imported fonts.
- **Export for other software.** `voiced.invoice.v1` JSON and line-item CSV for selected invoices
(see `docs/voiced-invoice-v1.md`).
@@ -85,11 +86,11 @@ The release procedure, including the manual first-run checklist, is in `docs/REL
## Installing
Download the files for your platform from the release page of the project on `git.bongbetic.com` (Releases, then
`v1.1.0`).
`v1.2.0`).
- **Windows 10/11:** run `Voiced_1.1.0_x64-setup.exe`. It is a per-user install and is not code-signed, so Windows
- **Windows 10/11:** run `Voiced_1.2.0_x64-setup.exe`. It is a per-user install and is not code-signed, so Windows
SmartScreen may warn: choose "More info", then "Run anyway".
- **Void Linux:** install `voiced-1.1.0_1.x86_64.xbps` from a local repository (see "Installing on Void Linux").
- **Void Linux:** install `voiced-1.2.0_1.x86_64.xbps` from a local repository (see "Installing on Void Linux").
On first launch the setup wizard asks for your business details, logo, bank account and invoice numbering. Nothing is
pre-filled. Uninstalling does not delete your data (see "Where data lives"); to start from scratch use
@@ -126,6 +127,16 @@ Settings, Data tab.
backup files on storage you trust, do not email or upload them casually, and rotate the ERPNext API key if a
backup is lost.
## Reset all data
Settings, Data, Reset all data. This permanently deletes the database (invoices, clients, payments, business details
and settings), `assets/`, the PDF `archive/`, imported `fonts/` and the whole `backups/` folder, including automatic
backups and the `pre-restore-*` safety copies, then starts Voiced as if it were new (the setup wizard shows again).
You must type `RESET` to enable the button. The wipe is staged and applied on the next start, before the database is
opened, so the "Delete everything and restart" button restarts the app for you; if a file cannot be deleted the
wipe is retried on the next start. Backup zips you saved elsewhere are not touched. Take a backup first if you may
want the data back.
## Fonts and licences
Eleven families are bundled as static TTF files in `public/fonts/<family>/`, each with its `OFL.txt`: DM Sans,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "voiced",
"version": "1.1.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "voiced",
"version": "1.1.0",
"version": "1.2.0",
"license": "Apache-2.0",
"dependencies": {
"@carbon/icons-react": "^11.89.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "voiced",
"private": true,
"version": "1.1.0",
"version": "1.2.0",
"license": "Apache-2.0",
"description": "Voiced — offline invoicing desktop app by Bongbetic",
"type": "module",
+1 -1
View File
@@ -28,7 +28,7 @@
# pure Rust and need no extra system libraries.
#
pkgname=voiced
version=1.1.0
version=1.2.0
revision=1
archs="x86_64*"
hostmakedepends="rust nodejs pkg-config"
+1 -1
View File
@@ -4647,7 +4647,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "voiced"
version = "1.1.0"
version = "1.2.0"
dependencies = [
"base64 0.22.1",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "voiced"
version = "1.1.0"
version = "1.2.0"
license = "Apache-2.0"
description = "Voiced — offline invoicing desktop app by Bongbetic"
authors = ["Bongbetic"]
+6 -6
View File
@@ -52,11 +52,11 @@ pub const BACKUP_FORMAT: &str = "voiced.backup.v1";
pub const AUTO_KEEP: usize = 14;
const MANIFEST_NAME: &str = "manifest.json";
const DB_ENTRY: &str = "voiced.db";
const ROOTS: [&str; 3] = ["assets", "archive", "fonts"];
const MARKER_NAME: &str = "pending_restore.json";
const LAST_RESTORE_NAME: &str = "last_restore.json";
const STAGING_NAME: &str = "pending-restore";
pub(super) const DB_ENTRY: &str = "voiced.db";
pub(super) const ROOTS: [&str; 3] = ["assets", "archive", "fonts"];
pub(super) const MARKER_NAME: &str = "pending_restore.json";
pub(super) const LAST_RESTORE_NAME: &str = "last_restore.json";
pub(super) const STAGING_NAME: &str = "pending-restore";
const AUTO_PREFIX: &str = "voiced-auto-";
const AUTO_SUFFIX: &str = ".zip";
@@ -100,7 +100,7 @@ impl DataDirs {
self.data.join(LAST_RESTORE_NAME)
}
/// Where a root (`assets`, `archive`, `fonts`) lives.
fn root_dir(&self, root: &str) -> PathBuf {
pub(super) fn root_dir(&self, root: &str) -> PathBuf {
match root {
"assets" => self.data.join("assets"),
_ => self.local.join(root),
+1
View File
@@ -10,5 +10,6 @@ pub mod logo;
pub mod payments;
pub mod presets;
pub mod raw;
pub mod reset;
pub mod series;
pub mod settings;
+231
View File
@@ -0,0 +1,231 @@
//! Reset all data: wipe everything the app owns, then start blank (the first-run wizard shows again).
//!
//! The running app holds `voiced.db` open (WAL) and serves files out of `assets/`, `archive/` and `fonts/`,
//! so deleting them live is unsafe. Like a restore, a reset has two phases:
//!
//! 1. `reset_all_data` (`stage_wipe_impl`) writes `<data>/pending_wipe.json` and touches nothing else.
//! 2. `apply_pending_wipe` runs in `init_state` BEFORE a staged restore is applied and before the database is
//! opened. It deletes exactly the names in `OWNED_DB_FILES` and `OWNED_DIRS`, never anything else in the
//! data directories (on Linux both roots are the same folder and may hold unrelated files), and removes the
//! marker last, so a failed wipe is retried on the next start. The frontend restarts the app after staging.
//!
//! This also deletes the backups (`<data>/backups`, including pre-restore safety copies and pre-migration
//! copies); that is the point of a reset.
use super::backup::{DataDirs, DB_ENTRY, LAST_RESTORE_NAME, MARKER_NAME, ROOTS, STAGING_NAME};
use super::raw::write_atomic;
use crate::AppState;
use std::fs;
use std::path::Path;
use tauri::State;
const WIPE_MARKER_NAME: &str = "pending_wipe.json";
/// The database and its SQLite sidecars, in the data dir.
const OWNED_DB_FILES: [&str; 4] = [
DB_ENTRY,
"voiced.db-wal",
"voiced.db-shm",
"voiced.db-journal",
];
/// Marker files of the restore mechanism, in the data dir.
const OWNED_MARKERS: [&str; 2] = [MARKER_NAME, LAST_RESTORE_NAME];
#[derive(Debug, PartialEq, Eq)]
pub enum WipeOutcome {
NothingPending,
Wiped,
/// Something could not be deleted; the marker is kept so the next start retries.
Failed {
message: String,
},
}
fn marker(dirs: &DataDirs) -> std::path::PathBuf {
dirs.data.join(WIPE_MARKER_NAME)
}
/// Remove a file or directory tree if it exists. A symlink is unlinked, never followed.
fn remove_if_exists(path: &Path) -> Result<(), String> {
let meta = match fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(format!("Could not delete {}: {e}", path.display())),
};
let result = if meta.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
};
result.map_err(|e| format!("Could not delete {}: {e}", path.display()))
}
/// Record that the next start must wipe everything. The live data is not touched.
pub fn stage_wipe_impl(dirs: &DataDirs) -> Result<(), String> {
write_atomic(&marker(dirs), b"{}")
}
/// Call after the data directories exist and before anything else (restore, database) uses them.
pub fn apply_pending_wipe(dirs: &DataDirs) -> WipeOutcome {
if fs::symlink_metadata(marker(dirs)).is_err() {
return WipeOutcome::NothingPending;
}
let mut targets = Vec::new();
// Order matters: the database goes first so a half-finished wipe never leaves data with no settings row.
for name in OWNED_DB_FILES {
targets.push(dirs.data.join(name));
}
targets.push(dirs.backups());
for root in ROOTS {
targets.push(dirs.root_dir(root));
}
for name in OWNED_MARKERS {
targets.push(dirs.data.join(name));
}
targets.push(dirs.data.join(STAGING_NAME));
targets.push(dirs.local.join(STAGING_NAME));
let errors: Vec<String> = targets
.iter()
.filter_map(|p| remove_if_exists(p).err())
.collect();
if !errors.is_empty() {
return WipeOutcome::Failed {
message: errors.join("; "),
};
}
match remove_if_exists(&marker(dirs)) {
Ok(()) => WipeOutcome::Wiped,
Err(message) => WipeOutcome::Failed { message },
}
}
/// Phase one of a reset: stage the wipe. The app must be restarted to carry it out.
#[tauri::command]
pub fn reset_all_data(state: State<AppState>) -> Result<(), String> {
stage_wipe_impl(&DataDirs::new(&state.data_dir, &state.local_data_dir))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn populate(dirs: &DataDirs) {
for name in OWNED_DB_FILES.iter().chain(OWNED_MARKERS.iter()) {
fs::write(dirs.data.join(name), b"x").unwrap();
}
fs::create_dir_all(dirs.backups().join("auto")).unwrap();
fs::write(
dirs.backups().join("auto").join("voiced-auto-20260101.zip"),
b"x",
)
.unwrap();
for root in ROOTS {
fs::create_dir_all(dirs.root_dir(root)).unwrap();
fs::write(dirs.root_dir(root).join("f.bin"), b"x").unwrap();
}
for staging in [dirs.data.join(STAGING_NAME), dirs.local.join(STAGING_NAME)] {
fs::create_dir_all(&staging).unwrap();
fs::write(staging.join("manifest.json"), b"x").unwrap();
}
fs::write(dirs.data.join("unrelated.txt"), b"keep").unwrap();
fs::create_dir_all(dirs.data.join("other")).unwrap();
fs::write(dirs.data.join("other").join("keep.txt"), b"keep").unwrap();
}
fn assert_wiped(dirs: &DataDirs) {
for name in OWNED_DB_FILES.iter().chain(OWNED_MARKERS.iter()) {
assert!(!dirs.data.join(name).exists(), "{name}");
}
assert!(!dirs.backups().exists());
for root in ROOTS {
assert!(!dirs.root_dir(root).exists(), "{root}");
}
assert!(!dirs.data.join(STAGING_NAME).exists());
assert!(!dirs.local.join(STAGING_NAME).exists());
assert!(!marker(dirs).exists());
}
fn assert_unrelated_kept(dirs: &DataDirs) {
assert_eq!(fs::read(dirs.data.join("unrelated.txt")).unwrap(), b"keep");
assert!(dirs.data.join("other").join("keep.txt").exists());
}
#[test]
fn nothing_staged_means_nothing_is_deleted() {
let tmp = tempdir().unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
populate(&dirs);
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::NothingPending);
assert!(dirs.db().exists());
assert!(dirs.backups().exists());
}
#[test]
fn staging_alone_does_not_touch_live_data() {
let tmp = tempdir().unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
populate(&dirs);
stage_wipe_impl(&dirs).unwrap();
assert!(marker(&dirs).exists());
assert!(dirs.db().exists());
assert!(dirs.root_dir("archive").join("f.bin").exists());
}
#[test]
fn wipe_removes_owned_data_and_keeps_unrelated_files() {
let tmp = tempdir().unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
populate(&dirs);
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert_wiped(&dirs);
assert_unrelated_kept(&dirs);
assert!(dirs.data.exists());
}
#[test]
fn wipe_handles_separate_data_and_local_roots() {
let tmp = tempdir().unwrap();
let data = tmp.path().join("roaming");
let local = tmp.path().join("local");
fs::create_dir_all(&data).unwrap();
fs::create_dir_all(&local).unwrap();
let dirs = DataDirs::new(&data, &local);
populate(&dirs);
fs::write(local.join("unrelated-local.txt"), b"keep").unwrap();
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert_wiped(&dirs);
assert_unrelated_kept(&dirs);
assert!(local.join("unrelated-local.txt").exists());
}
#[test]
fn wipe_is_idempotent_when_things_are_missing() {
let tmp = tempdir().unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::NothingPending);
populate(&dirs);
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert_wiped(&dirs);
}
#[cfg(unix)]
#[test]
fn a_symlinked_root_is_unlinked_not_followed() {
let tmp = tempdir().unwrap();
let outside = tempdir().unwrap();
fs::write(outside.path().join("precious.txt"), b"keep").unwrap();
let dirs = DataDirs::new(tmp.path(), tmp.path());
std::os::unix::fs::symlink(outside.path(), dirs.root_dir("archive")).unwrap();
stage_wipe_impl(&dirs).unwrap();
assert_eq!(apply_pending_wipe(&dirs), WipeOutcome::Wiped);
assert!(!dirs.root_dir("archive").exists());
assert!(outside.path().join("precious.txt").exists());
}
}
+10 -1
View File
@@ -74,8 +74,16 @@ fn init_state(app: &tauri::App) -> Result<AppState, Box<dyn std::error::Error>>
);
return Err(e.into());
}
// A restore staged by the Data tab is swapped in now, before anything opens the database.
// A reset staged by the Data tab is carried out first (it also discards any staged restore).
let dirs = commands::backup::DataDirs::new(&data_dir, &local_data_dir);
match commands::reset::apply_pending_wipe(&dirs) {
commands::reset::WipeOutcome::NothingPending => {}
commands::reset::WipeOutcome::Wiped => eprintln!("All data was reset."),
commands::reset::WipeOutcome::Failed { message } => {
eprintln!("Reset failed, it will be retried on the next start: {message}")
}
}
// A restore staged by the Data tab is swapped in now, before anything opens the database.
match commands::backup::apply_pending_restore(&dirs, chrono::Local::now()) {
commands::backup::ApplyOutcome::NothingPending => {}
commands::backup::ApplyOutcome::Applied { safety_dir } => {
@@ -186,6 +194,7 @@ pub fn run() {
commands::backup::get_backup_status,
commands::backup::set_auto_backup,
commands::backup::restart_app,
commands::reset::reset_all_data,
commands::invoice::list_invoice_ledger,
commands::assets::import_asset,
commands::assets::save_asset_bytes,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Voiced",
"version": "1.1.0",
"version": "1.2.0",
"identifier": "com.bongbetic.voiced",
"build": {
"beforeDevCommand": "npm run dev",
+73 -3
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { Button, InlineLoading, InlineNotification, Modal, Tile, Toggle } from "@carbon/react";
import { Button, InlineLoading, InlineNotification, Modal, TextInput, Tile, Toggle } from "@carbon/react";
import { confirm, open, save } from "@tauri-apps/plugin-dialog";
import { api } from "../lib/api";
import type { Settings } from "../lib/types";
@@ -11,6 +11,7 @@ import {
type BackupStatus,
type PendingRestore,
} from "../lib/backup";
import { clearVoicedLocalState, isResetConfirmed, RESET_WORD } from "../lib/reset";
import { useReturnFocus } from "../hooks/useReturnFocus";
import { useToast } from "./ToastProvider";
@@ -28,7 +29,7 @@ interface DataBackupPanelProps {
onRerunSetup: () => void;
}
/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup and re-running setup. */
/** Settings > Data: manual backup, restore (staged, applied after a restart), the automatic daily backup, re-running setup and a full reset. */
export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPanelProps) {
const toast = useToast();
const [status, setStatus] = useState<BackupStatus | null>(null);
@@ -38,7 +39,10 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
const [staged, setStaged] = useState<PendingRestore | null>(null);
const [restoreError, setRestoreError] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);
useReturnFocus(restorePath !== null);
const [resetOpen, setResetOpen] = useState(false);
const [resetTyped, setResetTyped] = useState("");
const [resetting, setResetting] = useState(false);
useReturnFocus(restorePath !== null || resetOpen);
const rerunSetup = async () => {
const ok = await confirm(
@@ -54,6 +58,31 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
}
};
const closeReset = () => {
if (resetting) return;
setResetOpen(false);
setResetTyped("");
};
// Stage the wipe, clear this webview's own saved state, then restart; the next start deletes the data.
const resetAll = async () => {
setResetting(true);
try {
await api.resetAllData();
} catch (e) {
setResetting(false);
toast.error("Could not reset", String(e));
return;
}
clearVoicedLocalState();
try {
await api.restartApp();
} catch (e) {
setResetting(false);
toast.error("Could not restart Voiced", `${String(e)} Close and reopen the app to finish the reset.`);
}
};
const refresh = useCallback(async () => {
try {
setStatus(await api.getBackupStatus());
@@ -264,6 +293,47 @@ export default function DataBackupPanel({ settings, onRerunSetup }: DataBackupPa
</div>
</Tile>
<Tile>
<h2 className="voiced-section-title">Reset all data</h2>
<p className="voiced-muted">
Permanently deletes every invoice, client, payment, your business details, archived PDFs and all backups, then starts Voiced
as if it were new.
</p>
<div className="voiced-actions">
<Button size="md" kind="danger--tertiary" onClick={() => setResetOpen(true)} disabled={resetOpen}>
Reset all data…
</Button>
</div>
</Tile>
<Modal
open={resetOpen}
danger
modalHeading="Reset all data?"
primaryButtonText={resetting ? "Resetting…" : "Delete everything and restart"}
secondaryButtonText="Cancel"
primaryButtonDisabled={!isResetConfirmed(resetTyped) || resetting}
onRequestClose={closeReset}
onRequestSubmit={() => void (isResetConfirmed(resetTyped) ? resetAll() : undefined)}
onSecondarySubmit={closeReset}
>
<div className="voiced-stack">
<p>
All invoices, clients, payments, vendor details, settings, logo and signature images, imported fonts, archived PDFs and
every backup (automatic, manual copies kept in Voiced&rsquo;s folder, and safety copies) will be permanently deleted. This
cannot be undone.
</p>
<p>Backup files you saved elsewhere are not touched. Voiced restarts and shows the setup wizard.</p>
<TextInput
id="resetConfirm"
labelText={`Type ${RESET_WORD} to confirm`}
value={resetTyped}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setResetTyped(e.target.value)}
autoComplete="off"
/>
</div>
</Modal>
<Modal
open={restorePath !== null}
danger={step === "confirm"}
+2
View File
@@ -122,6 +122,8 @@ export const api = {
getBackupStatus: () => invoke<BackupStatus>("get_backup_status"),
setAutoBackup: (enabled: boolean) => invoke<boolean>("set_auto_backup", { enabled }),
restartApp: () => invoke<void>("restart_app"),
// Stages a wipe of all data; it is carried out by the next start (see commands/reset.rs), so restart afterwards.
resetAllData: () => invoke<void>("reset_all_data"),
// ERPNext integration. All HTTP runs in Rust; the API secret is write-only from the webview.
erpnextGetConfig: () => invoke<ErpnextConfig>("erpnext_get_config"),
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearVoicedLocalState, isResetConfirmed } from "./reset";
describe("isResetConfirmed", () => {
it("accepts the word in any case with surrounding spaces", () => {
expect(isResetConfirmed("RESET")).toBe(true);
expect(isResetConfirmed(" reset ")).toBe(true);
});
it("rejects anything else", () => {
expect(isResetConfirmed("")).toBe(false);
expect(isResetConfirmed("rese")).toBe(false);
expect(isResetConfirmed("reset all")).toBe(false);
});
});
describe("clearVoicedLocalState", () => {
// The node test environment has no localStorage, so stub a minimal one.
beforeEach(() => {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
get length() {
return data.size;
},
key: (i: number) => [...data.keys()][i] ?? null,
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => void data.set(k, v),
removeItem: (k: string) => void data.delete(k),
});
});
afterEach(() => vi.unstubAllGlobals());
it("removes only the app's own keys", () => {
localStorage.setItem("voiced.theme", "g100");
localStorage.setItem("voiced.settingsTab", "data");
localStorage.setItem("other.key", "keep");
clearVoicedLocalState();
expect(localStorage.getItem("voiced.theme")).toBeNull();
expect(localStorage.getItem("voiced.settingsTab")).toBeNull();
expect(localStorage.getItem("other.key")).toBe("keep");
});
});
+18
View File
@@ -0,0 +1,18 @@
/** The word typed to enable "Reset all data". */
export const RESET_WORD = "RESET";
export const isResetConfirmed = (typed: string): boolean => typed.trim().toUpperCase() === RESET_WORD;
/** Removes every `voiced.*` key this app keeps in the webview's localStorage (theme, last tab, export mode). */
export function clearVoicedLocalState(): void {
try {
const keys: string[] = [];
for (let i = 0; i < localStorage.length; i += 1) {
const key = localStorage.key(i);
if (key && key.startsWith("voiced.")) keys.push(key);
}
keys.forEach((key) => localStorage.removeItem(key));
} catch {
// Storage can be blocked; there is then nothing to clear.
}
}