Compare commits

...
15 changed files with 519 additions and 61 deletions

No files matched your search

+6
View File
@@ -439,6 +439,12 @@ export const enTerminalMessages: Messages = {
'cloudSync.history.download': 'Download',
'cloudSync.history.resolved': 'Resolved',
'cloudSync.history.error': 'Error',
'cloudSync.localBackups.clearBusy': 'Finish restoring or recovering the vault before clearing backups.',
'cloudSync.localBackups.clear': 'Clear all backups',
'cloudSync.localBackups.clearConfirmTitle': 'Clear local backup history?',
'cloudSync.localBackups.clearConfirmDesc': 'Permanently delete all local vault backups on this device. Your current hosts, saved credentials and cloud copies will remain. This does not prepare the device for handover. New backups will still be created during normal use.',
'cloudSync.localBackups.clearSuccess': 'Local backup history cleared.',
'cloudSync.localBackups.clearFailed': 'Could not clear all local backups. Please retry.',
'cloudSync.localBackups.title': 'Local Backup History',
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
+6
View File
@@ -438,6 +438,12 @@ export const esTerminalMessages: Messages = {
'cloudSync.history.download': 'Descarga',
'cloudSync.history.resolved': 'Resuelto',
'cloudSync.history.error': 'Error',
'cloudSync.localBackups.clearBusy': 'Finaliza la restauración o recuperación de la bóveda antes de eliminar las copias.',
'cloudSync.localBackups.clear': 'Eliminar todas las copias',
'cloudSync.localBackups.clearConfirmTitle': '¿Eliminar el historial de copias locales?',
'cloudSync.localBackups.clearConfirmDesc': 'Elimina permanentemente todas las copias locales de la bóveda en este dispositivo. Los hosts actuales, las credenciales guardadas y las copias en la nube se conservan. Esto no prepara el dispositivo para entregarlo a otra persona. Se seguirán creando nuevas copias durante el uso normal.',
'cloudSync.localBackups.clearSuccess': 'Historial de copias locales eliminado.',
'cloudSync.localBackups.clearFailed': 'No se pudieron eliminar todas las copias locales. Inténtalo de nuevo.',
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
+6
View File
@@ -457,6 +457,12 @@ export const ruTerminalMessages: Messages = {
'cloudSync.history.download': 'Скачивание',
'cloudSync.history.resolved': 'Разрешено',
'cloudSync.history.error': 'Ошибка',
'cloudSync.localBackups.clearBusy': 'Завершите восстановление хранилища перед удалением резервных копий.',
'cloudSync.localBackups.clear': 'Удалить все копии',
'cloudSync.localBackups.clearConfirmTitle': 'Удалить историю локальных копий?',
'cloudSync.localBackups.clearConfirmDesc': 'Все локальные резервные копии хранилища на этом устройстве будут удалены безвозвратно. Текущие хосты, сохранённые учётные данные и облачные копии останутся. Это не очистка устройства для передачи другому человеку. При обычном использовании новые копии будут создаваться по-прежнему.',
'cloudSync.localBackups.clearSuccess': 'История локальных копий удалена.',
'cloudSync.localBackups.clearFailed': 'Не удалось удалить все локальные копии. Повторите попытку.',
'cloudSync.localBackups.title': 'История локальных резервных копий',
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
+6
View File
@@ -842,6 +842,12 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.history.download': '下载',
'cloudSync.history.resolved': '已解决',
'cloudSync.history.error': '错误',
'cloudSync.localBackups.clearBusy': '请先完成资料恢复或处理中断的恢复,再清空备份。',
'cloudSync.localBackups.clear': '清空全部备份',
'cloudSync.localBackups.clearConfirmTitle': '清空本地备份历史?',
'cloudSync.localBackups.clearConfirmDesc': '将永久删除此设备上全部主机库的历史备份。当前主机、保存的凭据和云端副本仍会保留,这不代表设备已完成交接清理。以后正常使用时仍会生成新备份。',
'cloudSync.localBackups.clearSuccess': '已清空本地备份历史。',
'cloudSync.localBackups.clearFailed': '未能清空全部本地备份,请重试。',
'cloudSync.localBackups.title': '本地备份历史',
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
'cloudSync.localBackups.retentionTitle': '备份保留数量',
+6
View File
@@ -842,6 +842,12 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.history.download': '下載',
'cloudSync.history.resolved': '已解決',
'cloudSync.history.error': '錯誤',
'cloudSync.localBackups.clearBusy': '請先完成資料還原或處理中斷的還原,再清空備份。',
'cloudSync.localBackups.clear': '清空全部備份',
'cloudSync.localBackups.clearConfirmTitle': '清空本機備份歷史?',
'cloudSync.localBackups.clearConfirmDesc': '將永久刪除此裝置上全部主機庫的歷史備份。目前的主機、儲存的憑證和雲端副本仍會保留,這不代表裝置已完成交接清理。日後正常使用時仍會產生新備份。',
'cloudSync.localBackups.clearSuccess': '已清空本機備份歷史。',
'cloudSync.localBackups.clearFailed': '未能清空全部本機備份,請重試。',
'cloudSync.localBackups.title': '本機備份歷史',
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
'cloudSync.localBackups.retentionTitle': '備份保留數量',
+87 -1
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import test, { type TestContext } from 'node:test';
import type { SyncPayload } from '../domain/sync.ts';
@@ -132,3 +132,89 @@ for (const localRestore of [false, true]) {
});
}
}
function installBackupLocks(t: TestContext) {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
let readers = 0;
let writer: Promise<void> | null = null;
const locks = {
async request(_name: string, options: { mode: string; ifAvailable?: boolean }, callback: (lock: object | null) => Promise<unknown>) {
if (options.mode === 'shared') {
if (writer) await writer;
readers += 1;
try { return await callback({}); } finally { readers -= 1; }
}
assert.equal(options.ifAvailable, true, 'cleanup must refuse rather than queue behind a restore');
if (readers || writer) return callback(null);
let release!: () => void;
writer = new Promise<void>((resolve) => { release = resolve; });
try { return await callback({}); } finally { writer = null; release(); }
},
};
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { locks } });
t.after(() => {
if (previous) Object.defineProperty(globalThis, 'navigator', previous);
else Reflect.deleteProperty(globalThis, 'navigator');
});
}
function installClearBridge(t: TestContext, clearVaultBackups: () => Promise<unknown>) {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: { clearVaultBackups } } });
t.after(() => {
if (previous) Object.defineProperty(globalThis, 'window', previous);
else Reflect.deleteProperty(globalThis, 'window');
});
}
test('cleanup refuses while another restore is applying, including nested restore helpers', async (t) => {
installBackupLocks(t);
const { clearLocalVaultBackups, withRestoreBarrier, LocalVaultBackupBusyError } = await import('./localVaultBackups.ts');
let clears = 0;
installClearBridge(t, async () => { clears += 1; });
let finish!: () => void;
let entered!: () => void;
const active = new Promise<void>((resolve) => { entered = resolve; });
const gate = new Promise<void>((resolve) => { finish = resolve; });
const restoring = withRestoreBarrier(() => withRestoreBarrier(async () => { entered(); await gate; }));
await active;
try {
await assert.rejects(clearLocalVaultBackups(), LocalVaultBackupBusyError);
assert.equal(clears, 0);
} finally {
finish();
await restoring;
}
await clearLocalVaultBackups();
assert.equal(clears, 1);
});
test('restore waits for an in-progress cleanup before reading or applying data', async (t) => {
installBackupLocks(t);
const { clearLocalVaultBackups, withRestoreBarrier } = await import('./localVaultBackups.ts');
let finish!: () => void;
let entered!: () => void;
const active = new Promise<void>((resolve) => { entered = resolve; });
const gate = new Promise<void>((resolve) => { finish = resolve; });
installClearBridge(t, async () => { entered(); await gate; });
const clearing = clearLocalVaultBackups();
await active;
let applied = false;
const restoring = withRestoreBarrier(async () => { applied = true; });
await Promise.resolve();
assert.equal(applied, false);
finish();
await Promise.all([clearing, restoring]);
assert.equal(applied, true);
});
test('cleanup retains recovery backups after an interrupted protected apply', async (t) => {
installBackupLocks(t);
const { clearLocalVaultBackups, LocalVaultBackupBusyError } = await import('./localVaultBackups.ts');
const { STORAGE_KEY_VAULT_APPLY_IN_PROGRESS } = await import('../infrastructure/config/storageKeys.ts');
let clears = 0;
installClearBridge(t, async () => { clears += 1; });
localStorageValues.set(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS, JSON.stringify({ startedAt: Date.now(), protectiveBackupId: 'recovery' }));
await assert.rejects(clearLocalVaultBackups(), LocalVaultBackupBusyError);
assert.equal(clears, 0);
});
+28
View File
@@ -10,6 +10,15 @@ import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
import { hasMeaningfulSyncData } from './syncPayload';
const BACKUP_APPLY_LOCK = 'netcatty:vault-backup-apply';
export class LocalVaultBackupBusyError extends Error {
constructor() {
super('Finish restoring or recovering the vault before clearing backups.');
this.name = 'LocalVaultBackupBusyError';
}
}
/**
* Snapshot the current sync data version (the integer that increments
* on each successful cloud sync). Returns undefined when the value is
@@ -86,6 +95,16 @@ export async function trimLocalVaultBackups(maxCount = getLocalVaultBackupMaxCou
await bridge?.trimVaultBackups?.({ maxCount });
}
export async function clearLocalVaultBackups(): Promise<void> {
const bridge = netcattyBridge.get();
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
if (!bridge?.clearVaultBackups || !locks) throw new Error('Local backup cleanup is unavailable.');
await locks.request(BACKUP_APPLY_LOCK, { mode: 'exclusive', ifAvailable: true }, async (lock) => {
if (!lock || readInterruptedVaultApply()) throw new LocalVaultBackupBusyError();
await bridge.clearVaultBackups!();
});
}
export async function getLocalVaultBackupCapabilities(): Promise<{
encryptionAvailable: boolean;
}> {
@@ -265,6 +284,15 @@ export async function withRestoreBarrier<T>(
task: () => Promise<T>,
holdMs: number = RESTORE_BARRIER_HOLD_MS,
): Promise<T> {
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
// Shared locks allow existing nested restore helpers. Cleanup never queues
// an exclusive lock, so a nested helper cannot wait behind its own cleanup.
return locks
? locks.request(BACKUP_APPLY_LOCK, { mode: 'shared' }, () => runWithRestoreBarrier(task, holdMs))
: runWithRestoreBarrier(task, holdMs);
}
async function runWithRestoreBarrier<T>(task: () => Promise<T>, holdMs: number): Promise<T> {
const writeDeadline = () => {
try {
localStorageAdapter.writeNumber(
+15 -3
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
type LocalVaultBackupPreview,
clearLocalVaultBackups,
getLocalVaultBackupCapabilities,
getLocalVaultBackupMaxCount,
listLocalVaultBackups,
@@ -20,13 +21,15 @@ export function useLocalVaultBackups() {
// a destructive action that might later be disabled.
const [encryptionAvailable, setEncryptionAvailable] = useState<boolean | null>(null);
const refreshSequence = useRef(0);
const refreshBackups = useCallback(async () => {
const sequence = ++refreshSequence.current;
setIsLoading(true);
try {
const next = await listLocalVaultBackups();
setBackups(next);
if (sequence === refreshSequence.current) setBackups(next);
} finally {
setIsLoading(false);
if (sequence === refreshSequence.current) setIsLoading(false);
}
}, []);
@@ -76,6 +79,14 @@ export function useLocalVaultBackups() {
return sanitized;
}, [refreshBackups]);
const clearBackups = useCallback(async () => {
try {
await clearLocalVaultBackups();
} finally {
await refreshBackups();
}
}, [refreshBackups]);
const openBackupDirectory = useCallback(async () => {
await openLocalVaultBackupDir();
}, []);
@@ -86,6 +97,7 @@ export function useLocalVaultBackups() {
maxBackups,
encryptionAvailable,
refreshBackups,
clearBackups,
readBackup: readLocalVaultBackup,
setMaxBackups: updateMaxBackups,
openBackupDirectory,
+42 -16
View File
@@ -1,22 +1,48 @@
/** Serialized Web Locks fixture for tests with a simulated browser window. */
/** Web Locks fixture for tests with a simulated browser window. */
export function installBrowserLockFixture(): void {
const tails = new Map<string, Promise<void>>();
const states = new Map<string, {
readers: number;
writer: boolean;
pending: Array<{ mode: LockMode; grant: () => void }>;
}>();
const locks = {
async request<T>(name: string, task: () => Promise<T>): Promise<T> {
const previous = tails.get(name) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const tail = previous.then(() => gate);
tails.set(name, tail);
await previous;
try {
return await task();
} finally {
release();
if (tails.get(name) === tail) tails.delete(name);
async request<T>(
name: string,
optionsOrTask: LockOptions | ((lock: Pick<Lock, "name" | "mode"> | null) => T | Promise<T>),
callback?: (lock: Pick<Lock, "name" | "mode"> | null) => T | Promise<T>,
): Promise<T> {
const options = typeof optionsOrTask === "function" ? {} : optionsOrTask;
const task = typeof optionsOrTask === "function" ? optionsOrTask : callback!;
const mode = options.mode ?? "exclusive";
const state = states.get(name) ?? { readers: 0, writer: false, pending: [] };
states.set(name, state);
if (options.ifAvailable && (state.writer || state.pending.length || (mode === "exclusive" && state.readers))) {
return task(null);
}
const drain = () => {
while (!state.writer && state.pending.length) {
const next = state.pending[0];
if (next.mode === "exclusive" && state.readers) return;
state.pending.shift();
if (next.mode === "exclusive") state.writer = true;
else state.readers += 1;
next.grant();
}
};
return new Promise<T>((resolve, reject) => {
state.pending.push({ mode, grant: () => {
void (async () => {
try {
return await task({ name, mode });
} finally {
if (mode === "exclusive") state.writer = false;
else state.readers -= 1;
drain();
}
})().then(resolve, reject);
} });
drain();
});
},
};
Object.defineProperty(globalThis, "navigator", {
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { I18nProvider } from '../application/i18n/I18nProvider';
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from './test-support/renderReactDom';
test('backup cleanup confirms, preserves data on cancel, reports errors and refreshes after retry', async () => {
const dom = installDomEnvironment();
const globals = {
localStorage: dom.window.localStorage,
MutationObserver: dom.window.MutationObserver,
NodeFilter: dom.window.NodeFilter,
HTMLInputElement: dom.window.HTMLInputElement,
};
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
Object.defineProperty(dom.window.navigator, 'locks', { configurable: true, value: {
request: async (_name: string, _options: unknown, callback: (lock: object) => Promise<void>) => callback({}),
} });
const { LocalBackupsPanel } = await import('./cloud-sync/CloudSyncLocalBackupsPanel');
const { toast } = await import('./ui/toast');
const renderer = await createDomRenderer(dom.document);
const notices: string[] = [];
const success = toast.success;
const error = toast.error;
toast.success = (message) => { notices.push(`success:${message}`); };
toast.error = (message) => { notices.push(`error:${message}`); };
let calls = 0;
let fail = true;
let hasBackup = true;
let finish: (() => void) | undefined;
let finishProbe: ((result: { encryptionAvailable: boolean }) => void) | undefined;
Object.defineProperty(dom.window, 'netcatty', { configurable: true, value: {
// Cleanup must remain available even when encrypted backups cannot be read.
getVaultBackupCapabilities: () => new Promise((resolve) => { finishProbe = resolve; }),
listVaultBackups: async () => hasBackup ? [{
id: 'fixture', createdAt: 1, reason: 'before_restore', fingerprint: 'fixture',
preview: { hostCount: 1, keyCount: 0, snippetCount: 0, noteCount: 0, identityCount: 0, portForwardingRuleCount: 0 },
}] : [],
clearVaultBackups: async () => {
calls += 1;
if (fail) throw new Error('access denied');
await new Promise<void>((resolve) => { finish = resolve; });
hasBackup = false;
return { deletedCount: 1 };
},
} });
const click = async (node: Element) => {
await dispatchDomEvent(node, new dom.window.MouseEvent('click', { bubbles: true }));
await flushEffects();
};
const button = (text: string, scope: ParentNode = dom.document) => {
const node = [...scope.querySelectorAll('button')].find((el) => el.textContent === text);
assert.ok(node, `button ${text}`);
return node as HTMLButtonElement;
};
try {
await renderer.render(<I18nProvider locale="en"><LocalBackupsPanel onApplyPayload={() => { throw new Error('must not restore'); }} /></I18nProvider>);
await flushEffects();
assert.doesNotMatch(dom.document.body.textContent ?? '', /Master key required/);
assert.equal([...dom.document.querySelectorAll('button')].some((el) => el.textContent === 'Restore'), false);
await runWithAct(async () => { finishProbe?.({ encryptionAvailable: false }); });
await flushEffects();
assert.doesNotMatch(dom.document.body.textContent ?? '', /Master key required/);
assert.equal([...dom.document.querySelectorAll('button')].some((el) => el.textContent === 'Restore'), false);
await click(button('Clear all backups'));
let dialog = dom.document.querySelector('[role="dialog"]');
assert.ok(dialog);
assert.match(dialog.textContent ?? '', /current hosts, saved credentials and cloud copies will remain/);
await click(button('Cancel', dialog));
assert.equal(calls, 0);
assert.equal(hasBackup, true);
await click(button('Clear all backups'));
dialog = dom.document.querySelector('[role="dialog"]');
assert.ok(dialog);
await click(button('Clear all backups', dialog));
assert.equal(calls, 1);
assert.match(notices[0], /^error:/);
assert.equal(hasBackup, true);
dialog = dom.document.querySelector('[role="dialog"]');
assert.ok(dialog);
fail = false;
await click(button('Clear all backups', dialog));
assert.equal(calls, 2);
assert.equal(button('Clear all backups', dialog).disabled, true);
assert.equal(button('Cancel', dialog).disabled, true);
assert.equal(notices.length, 1);
await runWithAct(async () => { finish?.(); });
await flushEffects();
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
assert.match(dom.document.body.textContent ?? '', /No local backups yet/);
assert.match(notices[1], /^success:/);
} finally {
toast.success = success;
toast.error = error;
await renderer.unmount();
await new Promise((resolve) => setTimeout(resolve, 0));
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.cleanup();
}
});
@@ -14,9 +14,11 @@ import {
FolderOpen,
Loader2,
RefreshCw,
Trash2,
} from 'lucide-react';
import { useLocalVaultBackups } from '../../application/state/useLocalVaultBackups';
import {
LocalVaultBackupBusyError,
MAX_LOCAL_VAULT_BACKUP_MAX_COUNT,
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
withRestoreBarrier,
@@ -29,6 +31,7 @@ import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Input } from '../ui/input';
import { ConfirmDialog } from '../ui/confirm-dialog';
import { toast } from '../ui/toast';
// ============================================================================
@@ -54,10 +57,13 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
maxBackups,
encryptionAvailable,
refreshBackups,
clearBackups,
readBackup,
setMaxBackups,
openBackupDirectory,
} = useLocalVaultBackups();
const [showClearDialog, setShowClearDialog] = useState(false);
const [isClearing, setIsClearing] = useState(false);
const [maxBackupsInput, setMaxBackupsInput] = useState(String(maxBackups));
const [isSavingMaxBackups, setIsSavingMaxBackups] = useState(false);
const [restoringBackupId, setRestoringBackupId] = useState<string | null>(null);
@@ -175,34 +181,34 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
}
};
const restoreAllowed = restoreDisabledReason === null;
// While encryptionAvailable is still `null` we're mid-probe — render the
// restore button as disabled so the user never sees a path they can't
// actually take (I1 surface). Once resolved, `false` hides the panel body
// via the unavailable banner below.
const encryptionResolved = encryptionAvailable !== null;
const encryptionUsable = encryptionAvailable === true;
const performClear = async () => {
if (isClearing) return;
setIsClearing(true);
try {
await clearBackups();
setShowClearDialog(false);
toast.success(t('cloudSync.localBackups.clearSuccess'));
} catch (error) {
toast.error(t(error instanceof LocalVaultBackupBusyError
? 'cloudSync.localBackups.clearBusy'
: 'cloudSync.localBackups.clearFailed'), t('sync.toast.errorTitle'));
} finally {
setIsClearing(false);
}
};
// safeStorage probe finished and returned "not available" → disable the
// panel entirely; the main process refuses to write in this state (I1).
if (encryptionResolved && !encryptionUsable) {
return (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertTriangle size={16} />
<span className="text-sm font-medium">
{t('cloudSync.localBackups.unavailableTitle')}
</span>
</div>
<div className="text-xs text-muted-foreground">
{t('cloudSync.localBackups.unavailableDesc')}
</div>
</div>
);
}
const restoreAllowed = restoreDisabledReason === null && encryptionAvailable === true;
return (
<div className="space-y-4">
{encryptionAvailable === false && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
<div className="text-sm font-medium text-amber-600 dark:text-amber-400">
{t('cloudSync.localBackups.unavailableTitle')}
</div>
<div className="text-xs text-muted-foreground">{t('cloudSync.localBackups.unavailableDesc')}</div>
</div>
)}
<div className="rounded-lg border bg-card p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="max-w-lg">
@@ -224,7 +230,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
<Button
variant="outline"
onClick={() => void handleSaveMaxBackups()}
disabled={isSavingMaxBackups}
disabled={isSavingMaxBackups || isClearing || restoringBackupId !== null}
className="gap-2"
>
{isSavingMaxBackups && <Loader2 size={14} className="animate-spin" />}
@@ -235,7 +241,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
</div>
</div>
{!restoreAllowed && (
{restoreDisabledReason !== null && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 mb-1">
<AlertTriangle size={14} />
@@ -248,19 +254,19 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
)}
<div className="rounded-lg border bg-card p-4 space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">{t('cloudSync.localBackups.title')}</div>
<div className="text-xs text-muted-foreground mt-1">
{t('cloudSync.localBackups.desc')}
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => void refreshBackups()}
disabled={isLoading}
disabled={isLoading || isClearing}
className="gap-1"
>
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
@@ -275,6 +281,16 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
<FolderOpen size={14} />
{t('settings.system.openFolder')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowClearDialog(true)}
disabled={isLoading || isClearing || isSavingMaxBackups || restoringBackupId !== null}
className="gap-1 text-destructive hover:text-destructive"
>
<Trash2 size={14} />
{t('cloudSync.localBackups.clear')}
</Button>
</div>
</div>
@@ -339,7 +355,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
// clicks here would interleave destructive
// writes and the second run's sentinel-clear
// could mask a still-partial first apply.
disabled={restoringBackupId !== null}
disabled={restoringBackupId !== null || isClearing}
className="gap-2"
>
{restoringBackupId === backup.id ? (
@@ -356,6 +372,17 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
)}
</div>
<ConfirmDialog
open={showClearDialog}
title={t('cloudSync.localBackups.clearConfirmTitle')}
message={t('cloudSync.localBackups.clearConfirmDesc')}
confirmLabel={t('cloudSync.localBackups.clear')}
busy={isClearing}
destructive
onOpenChange={(open) => { if (!isClearing) setShowClearDialog(open); }}
onConfirm={() => void performClear()}
/>
{/* Restore confirmation dialog (I2). Keeps the destructive action
gated behind an explicit second click, mirroring the clear-local
dialog elsewhere in this screen. */}
@@ -416,7 +443,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
<Button
variant="outline"
onClick={() => setPendingRestoreBackup(null)}
disabled={restoringBackupId !== null}
disabled={restoringBackupId !== null || isClearing}
>
{t('cloudSync.localBackups.restoreConfirmCancel')}
</Button>
@@ -428,7 +455,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
setPendingRestoreBackup(null);
await performRestore(target.id);
}}
disabled={restoringBackupId !== null}
disabled={restoringBackupId !== null || isClearing}
className="gap-2"
>
{restoringBackupId !== null ? (
+54 -9
View File
@@ -312,12 +312,17 @@ function createVaultBackupService({ app, safeStorage, shell }) {
const getBackupDir = () => path.join(app.getPath("userData"), BACKUP_DIR_NAME);
// Serialize createBackup so two concurrent calls (version-change backup
// Serialize backup mutations so two concurrent calls (version-change backup
// running at startup + an explicit protective-before-restore triggered
// by the user's click, etc.) observe each other's writes. Without this,
// both observers would see an empty directory, compute the same
// fingerprint, skip the dedupe, and write two identical files.
let createBackupLock = Promise.resolve();
let mutationLock = Promise.resolve();
const mutate = (operation) => {
const next = mutationLock.then(operation);
mutationLock = next.catch(() => undefined);
return next;
};
// Monotonically increasing `createdAt` per service instance. `Date.now()`
// has 1ms resolution and back-to-back async calls (version-change backup
// followed immediately by a protective backup) can land in the same
@@ -332,12 +337,7 @@ function createVaultBackupService({ app, safeStorage, shell }) {
},
async createBackup(options = {}) {
const next = createBackupLock.then(() => doCreateBackup(options));
// Swallow the rejection on the lock chain so one caller's error
// does not poison subsequent calls; each individual await sees its
// own rejection via the `next` return.
createBackupLock = next.catch(() => undefined);
return next;
return mutate(() => doCreateBackup(options));
},
async listBackups() {
@@ -364,7 +364,44 @@ function createVaultBackupService({ app, safeStorage, shell }) {
},
async trimBackups(options = {}) {
return pruneBackupRecords(getBackupDir(), options.maxCount);
return mutate(() => pruneBackupRecords(getBackupDir(), options.maxCount));
},
async clearBackups() {
return mutate(async () => {
const dirPath = getBackupDir();
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 });
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
let deletedCount = 0;
let failedCount = 0;
for (const entry of entries) {
// Include unreadable records and interrupted writes without reading
// or decrypting them. Preserve unrelated files and directories.
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
if (!entry.name.startsWith(BACKUP_FILE_PREFIX)) continue;
if (!entry.name.endsWith(BACKUP_FILE_EXT)
&& !entry.name.includes(`${BACKUP_FILE_EXT}.tmp-`)) continue;
try {
await fs.promises.unlink(path.join(dirPath, entry.name));
deletedCount += 1;
} catch (error) {
if (error.code !== "ENOENT") failedCount += 1;
}
}
// Persist directory changes before reporting success, including on a
// retry after a previous sync failure left no backup files to unlink.
// Windows does not support opening directory handles this way.
if (process.platform !== 'win32') {
const dirHandle = await fs.promises.open(dirPath, 'r');
try {
await dirHandle.sync();
} finally {
await dirHandle.close();
}
}
if (failedCount) throw new Error(`Could not delete ${failedCount} vault backup file(s).`);
return { deletedCount };
});
},
async openBackupDir() {
@@ -577,6 +614,14 @@ function registerHandlers(ipcMain, electronModule) {
}
return result;
});
ipcMain.handle("netcatty:vaultBackups:clear", async () => {
try {
return await service.clearBackups();
} finally {
// Refresh every window after partial deletion as well as full success.
broadcastBackupsChanged();
}
});
ipcMain.handle("netcatty:vaultBackups:openDir", async () => {
return service.openBackupDir();
});
@@ -716,3 +716,100 @@ test("readBackupRecord rejects oversized files before buffering them", async ()
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test('clearBackups removes encrypted, corrupt and interrupted backup files only', async () => {
const root = createTempRoot();
try {
const service = createService(root);
await service.createBackup({ payload: samplePayload() });
const dir = path.join(root, BACKUP_DIR_NAME);
fs.writeFileSync(path.join(dir, 'vault-backup-bad.json'), '{broken');
fs.writeFileSync(path.join(dir, 'vault-backup-interrupted.json.tmp-abc'), 'secret');
fs.writeFileSync(path.join(dir, 'keep.txt'), 'unrelated');
fs.mkdirSync(path.join(dir, 'vault-backup-directory.json'));
const unavailable = createService(root, { encrypted: false });
assert.deepEqual(await unavailable.clearBackups(), { deletedCount: 3 });
assert.deepEqual(await service.listBackups(), []);
assert.deepEqual(fs.readdirSync(dir).sort(), ['keep.txt', 'vault-backup-directory.json']);
assert.deepEqual(await service.clearBackups(), { deletedCount: 0 });
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('clearBackups waits for pending creates and allows future backups', async () => {
const root = createTempRoot();
try {
const service = createService(root);
const create = service.createBackup({ payload: samplePayload() });
const clear = service.clearBackups();
await create;
assert.deepEqual(await clear, { deletedCount: 1 });
assert.deepEqual(await service.listBackups(), []);
const next = await service.createBackup({ payload: samplePayload() });
assert.equal(next.created, true);
assert.equal((await service.listBackups()).length, 1);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('clearBackups reports partial deletion failures and permits retry', async (t) => {
const root = createTempRoot();
const originalUnlink = fs.promises.unlink;
try {
const service = createService(root);
await service.createBackup({ payload: samplePayload() });
const dir = path.join(root, BACKUP_DIR_NAME);
const blocked = path.join(dir, 'vault-backup-blocked.json');
fs.writeFileSync(blocked, '{broken');
const mock = t.mock.method(fs.promises, 'unlink', async (target) => {
if (target === blocked) throw Object.assign(new Error('denied'), { code: 'EACCES' });
return originalUnlink(target);
});
await assert.rejects(service.clearBackups(), /Could not delete 1 vault backup file/);
assert.deepEqual(fs.readdirSync(dir), ['vault-backup-blocked.json']);
mock.mock.restore();
assert.deepEqual(await service.clearBackups(), { deletedCount: 1 });
assert.deepEqual(fs.readdirSync(dir), []);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('clearBackups persists deletion and retries a failed directory sync even when empty', {
skip: process.platform === 'win32',
}, async (t) => {
const root = createTempRoot();
try {
const service = createService(root);
await service.createBackup({ payload: samplePayload() });
const dir = path.join(root, BACKUP_DIR_NAME);
const originalOpen = fs.promises.open;
let syncCalls = 0;
let closeCalls = 0;
t.mock.method(fs.promises, 'open', async (target, ...args) => {
const handle = await originalOpen(target, ...args);
if (target !== dir) return handle;
return {
async sync() {
syncCalls += 1;
assert.deepEqual(fs.readdirSync(dir), []);
if (syncCalls === 1) throw new Error('fixture directory sync failed');
await handle.sync();
},
async close() {
closeCalls += 1;
await handle.close();
},
};
});
await assert.rejects(service.clearBackups(), /fixture directory sync failed/);
assert.equal(closeCalls, 1);
assert.deepEqual(await service.clearBackups(), { deletedCount: 0 });
assert.equal(syncCalls, 2);
assert.equal(closeCalls, 2);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
+2
View File
@@ -1354,6 +1354,8 @@ function createPreloadApi(ctx) {
ipcRenderer.invoke("netcatty:vaultBackups:read", payload),
trimVaultBackups: (payload) =>
ipcRenderer.invoke("netcatty:vaultBackups:trim", payload),
clearVaultBackups: () =>
ipcRenderer.invoke("netcatty:vaultBackups:clear"),
openVaultBackupDir: () =>
ipcRenderer.invoke("netcatty:vaultBackups:openDir"),
// Subscribe to cross-window "backups changed" events emitted by the
+1
View File
@@ -268,6 +268,7 @@ declare global {
payload: import('./domain/sync').SyncPayload;
}>;
trimVaultBackups?(payload: { maxCount: number }): Promise<{ deletedCount: number; keptCount: number }>;
clearVaultBackups?(): Promise<{ deletedCount: number }>;
openVaultBackupDir?(): Promise<{ success: boolean; path: string }>;
// Subscribe to main-process-driven "vault backups changed" events.
// Returns an unsubscribe callback. Undefined in non-Electron builds.