feat: enhance backup and restore functionality with integrity checks and progress tracking

- Added support for backup integrity verification during export and restore processes.
- Introduced progress dispatching for backup export and restore operations.
- Implemented new API endpoints for inspecting remote backup integrity.
- Enhanced user interface with progress indicators and warning dialogs for integrity issues.
- Updated localization strings for new features and user feedback.
- Refactored backup-related functions for better clarity and maintainability.
This commit is contained in:
shuaiplus
2026-03-28 05:52:47 +08:00
parent bd8e26d2ab
commit 2a7879efaa
18 changed files with 2250 additions and 225 deletions
+6
View File
@@ -106,13 +106,16 @@ export interface AppMainRoutesProps {
onRevokeInvite: (code: string) => Promise<void>;
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
}
export default function AppMainRoutes(props: AppMainRoutesProps) {
@@ -333,11 +336,14 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
currentUserId={props.profile?.id || null}
onExport={props.onExportBackup}
onImport={props.onImportBackup}
onImportAllowingChecksumMismatch={props.onImportBackupAllowingChecksumMismatch}
onLoadSettings={props.onLoadBackupSettings}
onListRemoteBackups={props.onListRemoteBackups}
onDownloadRemoteBackup={props.onDownloadRemoteBackup}
onInspectRemoteBackup={props.onInspectRemoteBackup}
onDeleteRemoteBackup={props.onDeleteRemoteBackup}
onRestoreRemoteBackup={props.onRestoreRemoteBackup}
onRestoreRemoteBackupAllowingChecksumMismatch={props.onRestoreRemoteBackupAllowingChecksumMismatch}
onSaveSettings={props.onSaveBackupSettings}
onRunRemoteBackup={props.onRunRemoteBackup}
onNotify={props.onNotify}
+414 -19
View File
@@ -1,12 +1,15 @@
import { createPortal } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import ConfirmDialog from '@/components/ConfirmDialog';
import {
type AdminBackupImportResponse,
type AdminBackupRunResponse,
type AdminBackupSettings,
type BackupFileIntegrityCheckResult,
type BackupDestinationRecord,
type BackupDestinationType,
type RemoteBackupBrowserResponse,
verifyBackupFileIntegrity,
} from '@/lib/api/backup';
import {
REMOTE_BROWSER_ITEMS_PER_PAGE,
@@ -22,6 +25,7 @@ import {
loadPersistedRemoteBrowserState,
persistRemoteBrowserState,
} from '@/lib/backup-center';
import { BACKUP_PROGRESS_EVENT, type BackupProgressDetail, type BackupProgressOperation } from '@/lib/backup-restore-progress';
import { RECOMMENDED_PROVIDERS, type RecommendedProvider } from '@/lib/backup-recommendations';
import { t } from '@/lib/i18n';
import { BackupDestinationDetail } from './backup-center/BackupDestinationDetail';
@@ -32,16 +36,82 @@ interface BackupCenterPageProps {
currentUserId: string | null;
onExport: (includeAttachments?: boolean) => Promise<void>;
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadSettings: () => Promise<AdminBackupSettings>;
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
}
type PendingRestoreIntegrity =
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
| { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult };
interface BackupProgressPhase {
titleKey: string;
detailKey: string;
}
interface BackupProgressState {
operation: BackupProgressOperation;
source: 'local' | 'remote' | null;
includeAttachments: boolean;
fileLabel: string;
startedAt: number;
phaseIndex: number;
phases: BackupProgressPhase[];
currentTitleKey: string;
currentDetailKey: string;
}
const LOCAL_RESTORE_PHASES: BackupProgressPhase[] = [
{ titleKey: 'txt_backup_restore_progress_local_upload_title', detailKey: 'txt_backup_restore_progress_local_upload_detail' },
{ titleKey: 'txt_backup_restore_progress_local_shadow_title', detailKey: 'txt_backup_restore_progress_local_shadow_detail' },
{ titleKey: 'txt_backup_restore_progress_local_data_title', detailKey: 'txt_backup_restore_progress_local_data_detail' },
{ titleKey: 'txt_backup_restore_progress_local_files_title', detailKey: 'txt_backup_restore_progress_local_files_detail' },
{ titleKey: 'txt_backup_restore_progress_local_finalize_title', detailKey: 'txt_backup_restore_progress_local_finalize_detail' },
];
const REMOTE_RESTORE_PHASES: BackupProgressPhase[] = [
{ titleKey: 'txt_backup_restore_progress_remote_fetch_title', detailKey: 'txt_backup_restore_progress_remote_fetch_detail' },
{ titleKey: 'txt_backup_restore_progress_remote_shadow_title', detailKey: 'txt_backup_restore_progress_remote_shadow_detail' },
{ titleKey: 'txt_backup_restore_progress_remote_data_title', detailKey: 'txt_backup_restore_progress_remote_data_detail' },
{ titleKey: 'txt_backup_restore_progress_remote_files_title', detailKey: 'txt_backup_restore_progress_remote_files_detail' },
{ titleKey: 'txt_backup_restore_progress_remote_finalize_title', detailKey: 'txt_backup_restore_progress_remote_finalize_detail' },
];
const EXPORT_PROGRESS_PHASES: BackupProgressPhase[] = [
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_detail' },
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_detail' },
{ titleKey: 'txt_backup_archive_progress_ready_title', detailKey: 'txt_backup_archive_progress_ready_detail' },
{ titleKey: 'txt_backup_export_progress_save_title', detailKey: 'txt_backup_export_progress_save_detail' },
];
const EXPORT_WITH_ATTACHMENTS_PROGRESS_PHASES: BackupProgressPhase[] = [
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_with_attachments_detail' },
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_with_attachments_detail' },
{ titleKey: 'txt_backup_archive_progress_ready_title', detailKey: 'txt_backup_archive_progress_ready_detail' },
{ titleKey: 'txt_backup_export_progress_fetch_attachments_title', detailKey: 'txt_backup_export_progress_fetch_attachments_detail' },
{ titleKey: 'txt_backup_export_progress_rebuild_title', detailKey: 'txt_backup_export_progress_rebuild_detail' },
{ titleKey: 'txt_backup_export_progress_save_title', detailKey: 'txt_backup_export_progress_save_detail' },
];
const REMOTE_RUN_PROGRESS_PHASES: BackupProgressPhase[] = [
{ titleKey: 'txt_backup_remote_run_progress_prepare_title', detailKey: 'txt_backup_remote_run_progress_prepare_detail' },
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_with_attachments_detail' },
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_with_attachments_detail' },
{ titleKey: 'txt_backup_remote_run_progress_sync_attachments_title', detailKey: 'txt_backup_remote_run_progress_sync_attachments_detail' },
{ titleKey: 'txt_backup_remote_run_progress_upload_title', detailKey: 'txt_backup_remote_run_progress_upload_detail' },
{ titleKey: 'txt_backup_remote_run_progress_verify_title', detailKey: 'txt_backup_remote_run_progress_verify_detail' },
{ titleKey: 'txt_backup_remote_run_progress_cleanup_title', detailKey: 'txt_backup_remote_run_progress_cleanup_detail' },
];
function buildSkippedImportMessage(result: AdminBackupImportResponse): string | null {
const skipped = result.skipped;
if (!skipped || !skipped.attachments) return null;
@@ -51,10 +121,56 @@ function buildSkippedImportMessage(result: AdminBackupImportResponse): string |
});
}
function buildIntegrityStatusMessage(result: BackupFileIntegrityCheckResult, options?: { remote?: boolean }): string {
if (!result.hasChecksumPrefix) {
return t(options?.remote ? 'txt_backup_remote_restore_completed_without_checksum' : 'txt_backup_restore_completed_without_checksum');
}
return t(options?.remote ? 'txt_backup_remote_restore_completed_verified' : 'txt_backup_restore_completed_verified');
}
function buildIntegrityWarningMessage(entry: PendingRestoreIntegrity): string {
if (entry.source === 'remote') {
return t('txt_backup_remote_restore_checksum_warning_message', {
name: entry.fileName,
expected: entry.result.expectedPrefix || '-----',
actual: entry.result.actualPrefix,
});
}
return t('txt_backup_restore_checksum_warning_message', {
name: entry.fileName,
expected: entry.result.expectedPrefix || '-----',
actual: entry.result.actualPrefix,
});
}
function getBackupProgressPhases(
operation: BackupProgressOperation,
source: 'local' | 'remote' | null,
includeAttachments: boolean
): BackupProgressPhase[] {
if (operation === 'backup-restore') {
return source === 'remote' ? REMOTE_RESTORE_PHASES : LOCAL_RESTORE_PHASES;
}
if (operation === 'backup-export') {
return includeAttachments ? EXPORT_WITH_ATTACHMENTS_PROGRESS_PHASES : EXPORT_PROGRESS_PHASES;
}
return REMOTE_RUN_PROGRESS_PHASES;
}
function getBackupProgressTitleKey(state: BackupProgressState): string {
if (state.operation === 'backup-export') return 'txt_backup_export_progress_title';
if (state.operation === 'backup-remote-run') return 'txt_backup_remote_run_progress_title';
return state.source === 'remote'
? 'txt_backup_restore_progress_remote_title'
: 'txt_backup_restore_progress_local_title';
}
export default function BackupCenterPage(props: BackupCenterPageProps) {
const persistedRemoteStateRef = useRef(loadPersistedRemoteBrowserState(props.currentUserId));
const persistedRemoteState = persistedRemoteStateRef.current;
const fileInputRef = useRef<HTMLInputElement | null>(null);
const restoreProgressTimerRef = useRef<number | null>(null);
const restoreProgressPendingRef = useRef<BackupProgressState | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [exporting, setExporting] = useState(false);
@@ -67,14 +183,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [downloadingRemotePath, setDownloadingRemotePath] = useState('');
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
const [restoringRemotePath, setRestoringRemotePath] = useState('');
const [remoteRestoreStatusText, setRemoteRestoreStatusText] = useState('');
const [deletingRemotePath, setDeletingRemotePath] = useState('');
const [localError, setLocalError] = useState('');
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false);
const [confirmRemoteReplaceOpen, setConfirmRemoteReplaceOpen] = useState(false);
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
const [savedSettings, setSavedSettings] = useState<AdminBackupSettings | null>(null);
@@ -148,6 +267,59 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
});
}, [props.currentUserId, remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, selectedDestinationId]);
useEffect(() => {
if (!restoreProgress) {
setRestoreElapsedSeconds(0);
return;
}
setRestoreElapsedSeconds(Math.max(0, Math.floor((Date.now() - restoreProgress.startedAt) / 1000)));
const tickTimer = window.setInterval(() => {
setRestoreElapsedSeconds(Math.max(0, Math.floor((Date.now() - restoreProgress.startedAt) / 1000)));
}, 1000);
return () => window.clearInterval(tickTimer);
}, [restoreProgress]);
useEffect(() => {
const handleProgress = (event: Event) => {
const detail = (event as CustomEvent<BackupProgressDetail>).detail;
if (!detail) return;
const pending = restoreProgressPendingRef.current;
const operation = detail.operation || pending?.operation || 'backup-restore';
const source = (detail.source || pending?.source || null) as 'local' | 'remote' | null;
const includeAttachments = pending?.includeAttachments || false;
const phases = getBackupProgressPhases(operation, source, includeAttachments);
const matchedPhaseIndex = phases.findIndex((phase) => phase.titleKey === detail.stageTitle);
const phaseIndex = matchedPhaseIndex >= 0 ? matchedPhaseIndex : 0;
const nextState: BackupProgressState = {
operation,
source,
includeAttachments,
fileLabel: detail.fileName || pending?.fileLabel || '',
startedAt: pending?.operation === operation
? pending.startedAt
: Date.now(),
phaseIndex,
phases,
currentTitleKey: detail.stageTitle || phases[Math.max(0, phaseIndex)].titleKey,
currentDetailKey: detail.stageDetail || phases[Math.max(0, phaseIndex)].detailKey,
};
restoreProgressPendingRef.current = nextState;
if (restoreProgressTimerRef.current === null) {
setRestoreProgress(nextState);
}
if (detail.done) {
window.setTimeout(() => {
setRestoreProgress((current) => (
current && current.fileLabel === (detail.fileName || current.fileLabel) ? null : current
));
setRestoreElapsedSeconds(0);
}, detail.ok === false ? 1200 : 900);
}
};
window.addEventListener(BACKUP_PROGRESS_EVENT, handleProgress as EventListener);
return () => window.removeEventListener(BACKUP_PROGRESS_EVENT, handleProgress as EventListener);
}, []);
function updateSettings(mutator: (current: AdminBackupSettings) => AdminBackupSettings) {
setSettings((current) => {
const next = mutator(current);
@@ -225,6 +397,67 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
if (fileInputRef.current) fileInputRef.current.value = '';
}
function resetPendingIntegrityWarning() {
setPendingRestoreIntegrity(null);
setConfirmIntegrityWarningOpen(false);
}
function startRestoreProgress(
operation: BackupProgressOperation,
fileLabel: string,
options?: { source?: 'local' | 'remote' | null; includeAttachments?: boolean; delayMs?: number }
) {
if (restoreProgressTimerRef.current !== null) {
window.clearTimeout(restoreProgressTimerRef.current);
restoreProgressTimerRef.current = null;
}
setRestoreElapsedSeconds(0);
const source = options?.source || null;
const includeAttachments = !!options?.includeAttachments;
const phases = getBackupProgressPhases(operation, source, includeAttachments);
restoreProgressPendingRef.current = {
operation,
source,
includeAttachments,
fileLabel,
startedAt: Date.now(),
phaseIndex: 0,
phases,
currentTitleKey: phases[0].titleKey,
currentDetailKey: phases[0].detailKey,
};
restoreProgressTimerRef.current = window.setTimeout(() => {
restoreProgressTimerRef.current = null;
if (!restoreProgressPendingRef.current) return;
setRestoreProgress(restoreProgressPendingRef.current);
}, options?.delayMs ?? 480);
}
function clearRestoreProgress() {
if (restoreProgressTimerRef.current !== null) {
window.clearTimeout(restoreProgressTimerRef.current);
restoreProgressTimerRef.current = null;
}
restoreProgressPendingRef.current = null;
setRestoreProgress(null);
setRestoreElapsedSeconds(0);
}
async function inspectLocalBackupFile(file: File): Promise<BackupFileIntegrityCheckResult> {
const bytes = new Uint8Array(await file.arrayBuffer());
return verifyBackupFileIntegrity(bytes, file.name || '');
}
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
const payload = await props.onInspectRemoteBackup(destinationId, path);
return {
source: 'remote',
path,
fileName: payload.fileName || path.split('/').pop() || path,
result: payload.integrity,
};
}
function handleAddDestination(type: BackupDestinationType) {
updateSettings((current) => {
const nextDestination = createDraftDestinationRecord(type, current.destinations.filter((destination) => destination.type === type).length + 1);
@@ -277,18 +510,24 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setLocalError('');
setExporting(true);
try {
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
await props.onExport(exportIncludeAttachments);
props.onNotify('success', t('txt_backup_export_success'));
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200);
} finally {
setExporting(false);
}
}
async function runLocalRestore(replaceExisting: boolean) {
async function runLocalRestore(
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (!selectedFile) {
const message = t('txt_backup_file_required');
setLocalError(message);
@@ -296,17 +535,29 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
return;
}
setLocalError('');
setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(false);
setConfirmIntegrityWarningOpen(false);
setImporting(true);
try {
const result = await props.onImport(selectedFile, replaceExisting);
props.onNotify('success', t('txt_backup_restore_success_relogin'));
const integrity = knownIntegrity || await inspectLocalBackupFile(selectedFile);
startRestoreProgress('backup-restore', selectedFile.name || t('txt_backup_import'), {
source: 'local',
delayMs: replaceExisting ? 480 : 1400,
});
const result = allowChecksumMismatch
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting)
: await props.onImport(selectedFile, replaceExisting);
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage);
resetSelectedFile();
setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(false);
resetPendingIntegrityWarning();
} catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) {
clearRestoreProgress();
setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(true);
return;
@@ -314,6 +565,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed');
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200);
} finally {
setImporting(false);
}
@@ -364,16 +616,21 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setRunningRemoteBackup(true);
setLocalError('');
try {
startRestoreProgress('backup-remote-run', selectedDestination.name || t('txt_backup_run_now'), {
source: 'remote',
includeAttachments: !!selectedDestination.includeAttachments,
});
const result = await props.onRunRemoteBackup(selectedDestination.id);
setSavedSettings(result.settings);
setSettings(result.settings);
setSelectedDestinationId(selectedDestination.id);
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
props.onNotify('success', t('txt_backup_remote_run_success'));
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.fileName }));
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200);
} finally {
setRunningRemoteBackup(false);
}
@@ -415,30 +672,88 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}
}
async function runRemoteRestore(path: string, replaceExisting: boolean) {
async function handleSelectedLocalFile(nextFile: File | null) {
setSelectedFile(nextFile);
setLocalError('');
resetPendingIntegrityWarning();
setConfirmLocalRestoreOpen(false);
if (!nextFile) return;
try {
const integrity = await inspectLocalBackupFile(nextFile);
if (!integrity.matches) {
setPendingRestoreIntegrity({
source: 'local',
fileName: nextFile.name,
result: integrity,
});
setConfirmIntegrityWarningOpen(true);
return;
}
setConfirmLocalRestoreOpen(true);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
setLocalError(message);
props.onNotify('error', message);
}
}
async function handlePromptRemoteRestore(path: string) {
if (!savedSelectedDestination) return;
setLocalError('');
resetPendingIntegrityWarning();
try {
const integrity = await inspectRemoteBackupFile(savedSelectedDestination.id, path);
if (!integrity.result.matches) {
setPendingRestoreIntegrity(integrity);
setConfirmIntegrityWarningOpen(true);
return;
}
await runRemoteRestore(path, false, false, integrity.result);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
setLocalError(message);
props.onNotify('error', message);
}
}
async function runRemoteRestore(
path: string,
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (!savedSelectedDestination) return;
setConfirmRemoteReplaceOpen(false);
setConfirmIntegrityWarningOpen(false);
setRestoringRemotePath(path);
setRemoteRestoreStatusText(replaceExisting ? t('txt_backup_remote_restore_stage_replace') : t('txt_backup_remote_restore_stage_prepare'));
setLocalError('');
try {
const result = await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
const integrity = knownIntegrity ? { result: knownIntegrity } : await inspectRemoteBackupFile(savedSelectedDestination.id, path);
startRestoreProgress('backup-restore', path.split('/').pop() || path, {
source: 'remote',
delayMs: replaceExisting ? 480 : 1400,
});
const result = allowChecksumMismatch
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting)
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
setConfirmRemoteReplaceOpen(false);
setPendingRemoteRestorePath('');
setRemoteRestoreStatusText('');
props.onNotify('success', t('txt_backup_restore_success_relogin'));
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage);
resetPendingIntegrityWarning();
} catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) {
setPendingRemoteRestorePath(path);
setConfirmRemoteReplaceOpen(true);
setRemoteRestoreStatusText('');
clearRestoreProgress();
return;
}
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
setRemoteRestoreStatusText('');
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200);
} finally {
setRestoringRemotePath('');
}
@@ -454,9 +769,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
disabled={disableWhileBusy}
onChange={(event) => {
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
setSelectedFile(nextFile);
setLocalError('');
if (nextFile) setConfirmLocalRestoreOpen(true);
void handleSelectedLocalFile(nextFile);
}}
/>
@@ -521,7 +834,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
if (savedSelectedDestination) showRemoteBrowserPath(savedSelectedDestination.id, path);
}}
onDownloadRemoteBackup={(path) => void handleDownloadRemote(path)}
onRestoreRemoteBackup={(path) => void runRemoteRestore(path, false)}
onRestoreRemoteBackup={(path) => void handlePromptRemoteRestore(path)}
onPromptDeleteRemoteBackup={(path) => {
setPendingRemoteDeletePath(path);
setConfirmRemoteDeleteOpen(true);
@@ -533,7 +846,49 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
/>
{localError ? <div className="local-error">{localError}</div> : null}
{!localError && remoteRestoreStatusText ? <div className="status-ok">{remoteRestoreStatusText}</div> : null}
{restoreProgress && typeof document !== 'undefined' ? createPortal((
<div className="restore-progress-overlay" aria-live="polite">
<section className="restore-progress-card restore-progress-modal">
<div className="restore-progress-head">
<div>
<div className="restore-progress-kicker">{t('txt_backup_progress_kicker')}</div>
<h3 className="restore-progress-title">
{t(getBackupProgressTitleKey(restoreProgress))}
</h3>
<p className="restore-progress-subtitle">
{t('txt_backup_progress_subject', { name: restoreProgress.fileLabel })}
</p>
</div>
<div className="restore-progress-elapsed">
{t('txt_backup_restore_progress_elapsed', { seconds: String(restoreElapsedSeconds) })}
</div>
</div>
<div className="restore-progress-meter">
<span
className="restore-progress-meter-bar"
style={{
width: `${((restoreProgress.phaseIndex + 1) / restoreProgress.phases.length) * 100}%`,
}}
/>
</div>
<div className="restore-progress-current">
<strong>{t(restoreProgress.currentTitleKey)}</strong>
<p>{t(restoreProgress.currentDetailKey)}</p>
</div>
<ol className="restore-progress-list">
{restoreProgress.phases.map((phase, index) => {
const status = index < restoreProgress.phaseIndex ? 'done' : index === restoreProgress.phaseIndex ? 'active' : 'pending';
return (
<li key={phase.titleKey} className={`restore-progress-item ${status}`}>
<span className="restore-progress-dot" />
<span className="restore-progress-item-text">{t(phase.titleKey)}</span>
</li>
);
})}
</ol>
</section>
</div>
), document.body) : null}
<ConfirmDialog
open={confirmLocalRestoreOpen}
@@ -546,6 +901,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
onCancel={() => {
setConfirmLocalRestoreOpen(false);
resetSelectedFile();
resetPendingIntegrityWarning();
}}
/>
@@ -558,11 +914,16 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
confirmDisabled={importing}
cancelDisabled={importing}
danger
onConfirm={() => void runLocalRestore(true)}
onConfirm={() => void runLocalRestore(
true,
pendingRestoreIntegrity?.source === 'local',
pendingRestoreIntegrity?.source === 'local' ? pendingRestoreIntegrity.result : undefined
)}
onCancel={() => {
if (importing) return;
setConfirmReplaceOpen(false);
resetSelectedFile();
resetPendingIntegrityWarning();
}}
/>
@@ -575,11 +936,45 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
confirmDisabled={!!restoringRemotePath}
cancelDisabled={!!restoringRemotePath}
danger
onConfirm={() => void runRemoteRestore(pendingRemoteRestorePath, true)}
onConfirm={() => void runRemoteRestore(
pendingRemoteRestorePath,
true,
pendingRestoreIntegrity?.source === 'remote' && pendingRestoreIntegrity.path === pendingRemoteRestorePath,
pendingRestoreIntegrity?.source === 'remote' && pendingRestoreIntegrity.path === pendingRemoteRestorePath
? pendingRestoreIntegrity.result
: undefined
)}
onCancel={() => {
if (restoringRemotePath) return;
setConfirmRemoteReplaceOpen(false);
setPendingRemoteRestorePath('');
resetPendingIntegrityWarning();
}}
/>
<ConfirmDialog
open={confirmIntegrityWarningOpen}
title={t('txt_backup_restore_checksum_warning_title')}
message={pendingRestoreIntegrity ? buildIntegrityWarningMessage(pendingRestoreIntegrity) : t('txt_backup_restore_checksum_warning_message_fallback')}
variant="warning"
confirmText={t('txt_backup_restore_checksum_warning_confirm')}
cancelText={t('txt_cancel')}
danger
onConfirm={() => {
if (!pendingRestoreIntegrity) return;
setConfirmIntegrityWarningOpen(false);
if (pendingRestoreIntegrity.source === 'local') {
void runLocalRestore(false, true, pendingRestoreIntegrity.result);
return;
}
void runRemoteRestore(pendingRestoreIntegrity.path, false, true, pendingRestoreIntegrity.result);
}}
onCancel={() => {
if (importing || restoringRemotePath) return;
resetPendingIntegrityWarning();
setPendingRemoteRestorePath('');
setConfirmLocalRestoreOpen(false);
resetSelectedFile();
}}
/>
+71 -6
View File
@@ -1,11 +1,14 @@
import { createPortal } from 'preact/compat';
import { useEffect, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import { TriangleAlert } from 'lucide-preact';
import { t } from '@/lib/i18n';
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
variant?: 'default' | 'warning';
showIcon?: boolean;
confirmText?: string;
cancelText?: string;
@@ -19,9 +22,49 @@ interface ConfirmDialogProps {
afterActions?: ComponentChildren;
}
function incrementDialogBodyLock() {
if (typeof document === 'undefined') return;
const body = document.body;
const nextCount = Number(body.dataset.dialogCount || '0') + 1;
body.dataset.dialogCount = String(nextCount);
body.classList.add('dialog-open');
}
function decrementDialogBodyLock() {
if (typeof document === 'undefined') return;
const body = document.body;
const nextCount = Math.max(0, Number(body.dataset.dialogCount || '0') - 1);
if (nextCount === 0) {
delete body.dataset.dialogCount;
body.classList.remove('dialog-open');
return;
}
body.dataset.dialogCount = String(nextCount);
}
export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | null) {
useEffect(() => {
if (!active) return;
incrementDialogBodyLock();
return () => decrementDialogBodyLock();
}, [active]);
useEffect(() => {
if (!active || !onCancel || typeof window === 'undefined') return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
event.preventDefault();
onCancel();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [active, onCancel]);
}
export default function ConfirmDialog(props: ConfirmDialogProps) {
const [present, setPresent] = useState(props.open);
const [closing, setClosing] = useState(false);
const canDismiss = !props.cancelDisabled && !closing && !props.hideCancel;
useEffect(() => {
if (props.open) {
@@ -38,19 +81,41 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
return () => window.clearTimeout(timer);
}, [props.open, present]);
if (!present) return null;
return (
<div className={`dialog-mask ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}>
useDialogLifecycle(present, canDismiss ? props.onCancel : null);
if (!present || typeof document === 'undefined') return null;
return createPortal((
<div
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
onClick={(event) => {
if (event.target !== event.currentTarget || !canDismiss) return;
props.onCancel();
}}
>
<form
className={`dialog-card ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
role="dialog"
aria-modal="true"
aria-label={props.title}
onSubmit={(e) => {
e.preventDefault();
if (props.confirmDisabled || closing) return;
props.onConfirm();
}}
>
{props.variant === 'warning' ? (
<>
<div className="dialog-warning-strip" aria-hidden="true" />
<div className="dialog-warning-head">
<div className="dialog-warning-badge" aria-hidden="true">
<TriangleAlert size={24} />
</div>
<div className="dialog-warning-kicker">{t('txt_warning')}</div>
</div>
</>
) : null}
<h3 className="dialog-title">{props.title}</h3>
<div className="dialog-message">{props.message}</div>
<div className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
{props.children}
<button
type="submit"
@@ -75,5 +140,5 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
{props.afterActions}
</form>
</div>
);
), document.body);
}
+14 -5
View File
@@ -1,9 +1,10 @@
import { useState } from 'preact/hooks';
import { argon2idAsync } from '@noble/hashes/argon2.js';
import { createPortal } from 'preact/compat';
import { strFromU8, unzipSync } from 'fflate';
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
import { Download, FileUp } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog';
import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { CiphersImportPayload } from '@/lib/api/vault';
import {
type EncryptedJsonMode,
@@ -311,6 +312,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false);
const [exportAuthPassword, setExportAuthPassword] = useState('');
const [importSummary, setImportSummary] = useState<ImportResultSummary | null>(null);
useDialogLifecycle(!!importSummary, importSummary ? () => setImportSummary(null) : null);
const commonSourceSet = new Set<ImportSourceId>(COMMON_IMPORT_SOURCE_IDS);
const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId));
const otherSources = IMPORT_SOURCES.filter((item) => !commonSourceSet.has(item.id as ImportSourceId));
@@ -803,9 +806,15 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
</label>
</ConfirmDialog>
{importSummary && (
<div className="dialog-mask">
<section className="dialog-card import-summary-dialog">
{importSummary && typeof document !== 'undefined' ? createPortal((
<div
className="dialog-mask"
onClick={(event) => {
if (event.target !== event.currentTarget) return;
setImportSummary(null);
}}
>
<section className="dialog-card import-summary-dialog" role="dialog" aria-modal="true" aria-label={t('txt_import_success')}>
<button
type="button"
className="import-summary-close"
@@ -866,7 +875,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
</button>
</section>
</div>
)}
), document.body) : null}
</div>
);
}