From f75d67ad4d239da7ed3ba2cd395e21fef983c1eb Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 21 Aug 2026 18:58:27 +0300 Subject: [PATCH] feat(app): make crash recovery configurable * feat(app): make crash recovery configurable - Add an enabled-by-default persisted recovery preference and General settings control - Stop recovery writes and remove the active document snapshot when disabled - Suppress startup recovery discovery while the preference is disabled - Cover disabled persistence and re-enable behavior * fix(i18n): translate recovery preferences * fix(app): serialize recovery disable cleanup - Block re-enabled persistence until pending snapshot removal completes - Preserve disable generations so stale cleanup cannot reset newer recovery state - Display runtime-overridden recovery state in Settings - Deep-clone nested preferences before updating recovery --- packages/vue/src/i18n/locales/de/dialogs.json | 4 + packages/vue/src/i18n/locales/es/dialogs.json | 6 +- packages/vue/src/i18n/locales/fr/dialogs.json | 6 +- packages/vue/src/i18n/locales/it/dialogs.json | 6 +- packages/vue/src/i18n/locales/ja/dialogs.json | 6 +- packages/vue/src/i18n/locales/pl/dialogs.json | 6 +- packages/vue/src/i18n/locales/ru/dialogs.json | 6 +- .../vue/src/i18n/locales/zh-cn/dialogs.json | 6 +- packages/vue/src/i18n/messages/dialogs.ts | 5 ++ src/app/document/io/source.ts | 2 + src/app/document/recovery/controller.ts | 45 ++++++++-- src/app/document/recovery/preferences.ts | 17 ++++ src/app/settings/preferences/store.ts | 23 +++-- src/components/recovery/RecoveryDialog.vue | 3 +- .../settings/general/GeneralSettingsPanel.vue | 27 ++++++ .../app/document/recovery/controller.test.ts | 89 ++++++++++++++++++- 16 files changed, 232 insertions(+), 25 deletions(-) create mode 100644 src/app/document/recovery/preferences.ts diff --git a/packages/vue/src/i18n/locales/de/dialogs.json b/packages/vue/src/i18n/locales/de/dialogs.json index 2ce019fef..a5dc8988d 100644 --- a/packages/vue/src/i18n/locales/de/dialogs.json +++ b/packages/vue/src/i18n/locales/de/dialogs.json @@ -7,6 +7,10 @@ "restore": "Wiederherstellen", "discard": "Verwerfen", "recoveryFailed": "Dieses Dokument konnte nicht wiederhergestellt werden.", + "settingsRecovery": "Wiederherstellung", + "settingsRecoveryDescription": "Lokale Wiederherstellungskopien für ungespeicherte Dokumente verwalten.", + "preserveUnsavedWork": "Ungespeicherte Arbeit automatisch sichern", + "preserveUnsavedWorkDescription": "Lokale Wiederherstellungskopien speichern, um Dokumente nach einem unerwarteten Beenden wiederherzustellen.", "rename": "Umbenennen", "renameLayers": "{count} Ebenen umbenennen", "renamePreview": "Vorschau", diff --git a/packages/vue/src/i18n/locales/es/dialogs.json b/packages/vue/src/i18n/locales/es/dialogs.json index 08184de53..702387da4 100644 --- a/packages/vue/src/i18n/locales/es/dialogs.json +++ b/packages/vue/src/i18n/locales/es/dialogs.json @@ -350,5 +350,9 @@ "mcpStatus_starting": "Starting", "mcpStatus_running": "Running", "mcpStatus_stopped": "Stopped", - "mcpStatus_error": "Error" + "mcpStatus_error": "Error", + "settingsRecovery": "Recuperación", + "settingsRecoveryDescription": "Controla las copias locales de recuperación de documentos no guardados.", + "preserveUnsavedWork": "Conservar automáticamente el trabajo no guardado", + "preserveUnsavedWorkDescription": "Guarda copias locales para restaurar documentos después de un cierre inesperado." } diff --git a/packages/vue/src/i18n/locales/fr/dialogs.json b/packages/vue/src/i18n/locales/fr/dialogs.json index 9324df091..f3a319062 100644 --- a/packages/vue/src/i18n/locales/fr/dialogs.json +++ b/packages/vue/src/i18n/locales/fr/dialogs.json @@ -350,5 +350,9 @@ "mcpStatus_starting": "Starting", "mcpStatus_running": "Running", "mcpStatus_stopped": "Stopped", - "mcpStatus_error": "Error" + "mcpStatus_error": "Error", + "settingsRecovery": "Récupération", + "settingsRecoveryDescription": "Gérez les copies locales de récupération des documents non enregistrés.", + "preserveUnsavedWork": "Conserver automatiquement le travail non enregistré", + "preserveUnsavedWorkDescription": "Stockez des copies locales afin de restaurer les documents après un arrêt inattendu." } diff --git a/packages/vue/src/i18n/locales/it/dialogs.json b/packages/vue/src/i18n/locales/it/dialogs.json index 12eb5d591..e9d7a0f7d 100644 --- a/packages/vue/src/i18n/locales/it/dialogs.json +++ b/packages/vue/src/i18n/locales/it/dialogs.json @@ -350,5 +350,9 @@ "mcpStatus_starting": "Starting", "mcpStatus_running": "Running", "mcpStatus_stopped": "Stopped", - "mcpStatus_error": "Error" + "mcpStatus_error": "Error", + "settingsRecovery": "Ripristino", + "settingsRecoveryDescription": "Gestisci le copie locali di ripristino dei documenti non salvati.", + "preserveUnsavedWork": "Conserva automaticamente il lavoro non salvato", + "preserveUnsavedWorkDescription": "Salva copie locali per ripristinare i documenti dopo una chiusura imprevista." } diff --git a/packages/vue/src/i18n/locales/ja/dialogs.json b/packages/vue/src/i18n/locales/ja/dialogs.json index fef44050e..a6122e85d 100644 --- a/packages/vue/src/i18n/locales/ja/dialogs.json +++ b/packages/vue/src/i18n/locales/ja/dialogs.json @@ -350,5 +350,9 @@ "mcpStatus_starting": "Starting", "mcpStatus_running": "Running", "mcpStatus_stopped": "Stopped", - "mcpStatus_error": "Error" + "mcpStatus_error": "Error", + "settingsRecovery": "復元", + "settingsRecoveryDescription": "未保存ドキュメントのローカル復元コピーを管理します。", + "preserveUnsavedWork": "未保存の作業を自動的に保持", + "preserveUnsavedWorkDescription": "予期しない終了後にドキュメントを復元できるよう、ローカルコピーを保存します。" } diff --git a/packages/vue/src/i18n/locales/pl/dialogs.json b/packages/vue/src/i18n/locales/pl/dialogs.json index 737b8c4a1..978a458e8 100644 --- a/packages/vue/src/i18n/locales/pl/dialogs.json +++ b/packages/vue/src/i18n/locales/pl/dialogs.json @@ -350,5 +350,9 @@ "mcpStatus_starting": "Starting", "mcpStatus_running": "Running", "mcpStatus_stopped": "Stopped", - "mcpStatus_error": "Error" + "mcpStatus_error": "Error", + "settingsRecovery": "Odzyskiwanie", + "settingsRecoveryDescription": "Zarządzaj lokalnymi kopiami odzyskiwania niezapisanych dokumentów.", + "preserveUnsavedWork": "Automatycznie zachowuj niezapisaną pracę", + "preserveUnsavedWorkDescription": "Przechowuj lokalne kopie, aby odtworzyć dokumenty po nieoczekiwanym zamknięciu." } diff --git a/packages/vue/src/i18n/locales/ru/dialogs.json b/packages/vue/src/i18n/locales/ru/dialogs.json index 9dc38dad2..a9d802f81 100644 --- a/packages/vue/src/i18n/locales/ru/dialogs.json +++ b/packages/vue/src/i18n/locales/ru/dialogs.json @@ -350,5 +350,9 @@ "mcpStatus_starting": "Starting", "mcpStatus_running": "Running", "mcpStatus_stopped": "Stopped", - "mcpStatus_error": "Error" + "mcpStatus_error": "Error", + "settingsRecovery": "Восстановление", + "settingsRecoveryDescription": "Управляйте локальными резервными копиями несохранённых документов.", + "preserveUnsavedWork": "Автоматически сохранять несохранённую работу", + "preserveUnsavedWorkDescription": "Храните локальные копии, чтобы восстановить документы после неожиданного завершения работы." } diff --git a/packages/vue/src/i18n/locales/zh-cn/dialogs.json b/packages/vue/src/i18n/locales/zh-cn/dialogs.json index 535c364b4..17aa2ca77 100644 --- a/packages/vue/src/i18n/locales/zh-cn/dialogs.json +++ b/packages/vue/src/i18n/locales/zh-cn/dialogs.json @@ -350,5 +350,9 @@ "mcpStatus_starting": "Starting", "mcpStatus_running": "Running", "mcpStatus_stopped": "Stopped", - "mcpStatus_error": "Error" + "mcpStatus_error": "Error", + "settingsRecovery": "恢复", + "settingsRecoveryDescription": "管理未保存文档的本地恢复副本。", + "preserveUnsavedWork": "自动保留未保存的工作", + "preserveUnsavedWorkDescription": "存储本地恢复副本,以便在意外关闭后恢复文档。" } diff --git a/packages/vue/src/i18n/messages/dialogs.ts b/packages/vue/src/i18n/messages/dialogs.ts index bb060b7c5..d22214e19 100644 --- a/packages/vue/src/i18n/messages/dialogs.ts +++ b/packages/vue/src/i18n/messages/dialogs.ts @@ -61,6 +61,11 @@ export const dialogMessageDefaults = { restore: 'Restore', discard: 'Discard', recoveryFailed: 'Could not restore this document.', + settingsRecovery: 'Recovery', + settingsRecoveryDescription: 'Control local crash-recovery copies for unsaved documents.', + preserveUnsavedWork: 'Automatically preserve unsaved work', + preserveUnsavedWorkDescription: + 'Store local recovery copies so documents can be restored after an unexpected shutdown.', showUI: params('Show UI ({shortcut})'), fontSettings: 'Font settings', fontIssueFound: '1 font face is unavailable or substituted', diff --git a/src/app/document/io/source.ts b/src/app/document/io/source.ts index 18e0d4184..4eee6010b 100644 --- a/src/app/document/io/source.ts +++ b/src/app/document/io/source.ts @@ -11,6 +11,7 @@ import { createSaveActions } from '@/app/document/io/save' import { createDocumentSourceState } from '@/app/document/io/source-state' import type { DocumentSourceAccess } from '@/app/document/io/types' import { createDocumentRecovery } from '@/app/document/recovery' +import { recoveryEnabled } from '@/app/document/recovery/preferences' import type { StorageDocumentBinding } from '@/app/integrations/storage/types' type DocumentSourceState = EditorState & { @@ -58,6 +59,7 @@ export function createDocumentSourceActions({ const recovery = createDocumentRecovery({ state, + isEnabled: () => recoveryEnabled.value, buildFigFile: buildRecoveryFigFile, hasWritableSource: () => !!getFileHandle() || !!getFilePath() || !!getStorageBinding() }) diff --git a/src/app/document/recovery/controller.ts b/src/app/document/recovery/controller.ts index 005ad9bd4..aad106e0b 100644 --- a/src/app/document/recovery/controller.ts +++ b/src/app/document/recovery/controller.ts @@ -1,5 +1,5 @@ import { watchDebounced } from '@vueuse/core' -import type { WatchHandle } from 'vue' +import { watch, type WatchHandle } from 'vue' import type { EditorState } from '@open-pencil/core/editor' @@ -13,6 +13,7 @@ interface DocumentRecoveryOptions { state: RecoveryState buildFigFile: () => Promise | Uint8Array hasWritableSource: () => boolean + isEnabled?: () => boolean store?: RecoveryStore recoveryId?: string } @@ -30,6 +31,7 @@ export function createDocumentRecovery({ state, buildFigFile, hasWritableSource, + isEnabled = () => true, store = getRecoveryStore(), recoveryId = createCanvasId() }: DocumentRecoveryOptions): DocumentRecoveryController { @@ -39,14 +41,15 @@ export function createDocumentRecovery({ let requestedVersion = protectedVersion let lifecycleGeneration = 0 let writing: Promise | null = null + let cleanup: Promise = Promise.resolve() let disposed = false async function runWrites(generation: number): Promise { - if (disposed || generation !== lifecycleGeneration) return + if (disposed || generation !== lifecycleGeneration || !isEnabled()) return if (hasWritableSource() || requestedVersion === protectedVersion) return const version = requestedVersion const bytes = await buildFigFile() - if (generation !== lifecycleGeneration || hasWritableSource()) return + if (generation !== lifecycleGeneration || hasWritableSource() || !isEnabled()) return await store.write({ id, documentName: state.documentName, @@ -60,7 +63,8 @@ export function createDocumentRecovery({ } async function persistNow(): Promise { - if (disposed || hasWritableSource()) return + await cleanup + if (disposed || hasWritableSource() || !isEnabled()) return requestedVersion = state.sceneVersion if (requestedVersion === protectedVersion) return if (!writing) { @@ -72,7 +76,7 @@ export function createDocumentRecovery({ await writing } - const stop: WatchHandle = watchDebounced( + const stopVersionWatch: WatchHandle = watchDebounced( () => state.sceneVersion, () => { void persistNow().catch((error) => console.warn('[Recovery] Snapshot failed:', error)) @@ -80,9 +84,35 @@ export function createDocumentRecovery({ { debounce: 3000, maxWait: 10000 } ) + const stopEnabledWatch: WatchHandle = watch( + isEnabled, + (enabled) => { + if (enabled) { + protectedVersion = state.sceneVersion + requestedVersion = state.sceneVersion + return + } + lifecycleGeneration++ + const cleanupGeneration = lifecycleGeneration + const snapshotId = id + requestedVersion = state.sceneVersion + protectedVersion = state.sceneVersion + const activeWrite = writing + cleanup = cleanup + .then(async () => { + await activeWrite + await store.remove(snapshotId) + if (cleanupGeneration === lifecycleGeneration) persistedVersion = null + return undefined + }) + .catch((error) => console.warn('[Recovery] Failed to disable recovery:', error)) + }, + { flush: 'sync' } + ) + async function invalidateActiveWrite(): Promise { lifecycleGeneration++ - await writing + await Promise.all([writing, cleanup]) } return { @@ -117,7 +147,8 @@ export function createDocumentRecovery({ disposeRecovery() { disposed = true lifecycleGeneration++ - stop() + stopVersionWatch() + stopEnabledWatch() } } } diff --git a/src/app/document/recovery/preferences.ts b/src/app/document/recovery/preferences.ts new file mode 100644 index 000000000..0d4b6a9f6 --- /dev/null +++ b/src/app/document/recovery/preferences.ts @@ -0,0 +1,17 @@ +import { computed, shallowRef, type ComputedRef } from 'vue' + +import { appPreferences, updateRecoveryEnabled } from '@/app/settings/preferences/store' + +const runtimeOverride = shallowRef(null) + +export const recoveryEnabled: ComputedRef = computed( + () => runtimeOverride.value ?? appPreferences.value.recovery.enabled +) + +export function setRecoveryEnabled(enabled: boolean): void { + updateRecoveryEnabled(enabled) +} + +export function setRecoveryRuntimeOverride(enabled: boolean | null): void { + runtimeOverride.value = enabled +} diff --git a/src/app/settings/preferences/store.ts b/src/app/settings/preferences/store.ts index 0c988e05d..fb5185a2e 100644 --- a/src/app/settings/preferences/store.ts +++ b/src/app/settings/preferences/store.ts @@ -4,6 +4,9 @@ import { DEFAULT_SNAPPING_PREFERENCES, type SnappingPreferences } from '@open-pe export interface AppPreferences { version: 1 + recovery: { + enabled: boolean + } editing: { snapping: SnappingPreferences } @@ -11,6 +14,7 @@ export interface AppPreferences { export const DEFAULT_APP_PREFERENCES: Readonly = { version: 1, + recovery: { enabled: true }, editing: { snapping: { ...DEFAULT_SNAPPING_PREFERENCES } } @@ -28,12 +32,9 @@ interface StoredSnappingPreferences { pixelGrid?: unknown } -interface StoredEditingPreferences { - snapping?: StoredSnappingPreferences -} - interface StoredAppPreferences { - editing?: StoredEditingPreferences + recovery?: { enabled?: unknown } + editing?: { snapping?: StoredSnappingPreferences } } function isStoredAppPreferences(value: unknown): value is StoredAppPreferences { @@ -41,10 +42,14 @@ function isStoredAppPreferences(value: unknown): value is StoredAppPreferences { } function normalizePreferences(value: unknown): AppPreferences { - const snapping = isStoredAppPreferences(value) ? value.editing?.snapping : undefined + const stored = isStoredAppPreferences(value) ? value : undefined + const snapping = stored?.editing?.snapping return { version: 1, + recovery: { + enabled: booleanOrDefault(stored?.recovery?.enabled, DEFAULT_APP_PREFERENCES.recovery.enabled) + }, editing: { snapping: { geometry: booleanOrDefault( @@ -70,6 +75,12 @@ export const appPreferences = useLocalStorage( { mergeDefaults: (storageValue) => normalizePreferences(storageValue) } ) +export function updateRecoveryEnabled(enabled: boolean): void { + const preferences = structuredClone(appPreferences.value) + preferences.recovery.enabled = enabled + appPreferences.value = preferences +} + export function updateSnappingPreferences(changes: Partial): void { appPreferences.value = { ...appPreferences.value, diff --git a/src/components/recovery/RecoveryDialog.vue b/src/components/recovery/RecoveryDialog.vue index 082e1060f..cb67c24f3 100644 --- a/src/components/recovery/RecoveryDialog.vue +++ b/src/components/recovery/RecoveryDialog.vue @@ -4,6 +4,7 @@ import { useRoute } from 'vue-router' import { AlertDialogCancel, AlertDialogDescription, AlertDialogTitle } from 'reka-ui' import { useI18n } from '@open-pencil/vue' +import { recoveryEnabled } from '@/app/document/recovery/preferences' import { useNotificationMessages } from '@/app/i18n/notifications' import { discardRecoverySnapshot, listRecoverySnapshots, restoreRecoverySnapshot } from '@/app/tabs' import type { RecoverySnapshotMeta } from '@/app/document/recovery' @@ -54,7 +55,7 @@ async function discard(snapshot: RecoverySnapshotMeta): Promise { } onMounted(async () => { - if (route.path !== '/') return + if (route.path !== '/' || !recoveryEnabled.value) return try { snapshots.value = await listRecoverySnapshots() open.value = snapshots.value.length > 0 diff --git a/src/components/settings/general/GeneralSettingsPanel.vue b/src/components/settings/general/GeneralSettingsPanel.vue index 22c12dc47..6c966d420 100644 --- a/src/components/settings/general/GeneralSettingsPanel.vue +++ b/src/components/settings/general/GeneralSettingsPanel.vue @@ -2,12 +2,18 @@ import { computed } from 'vue' import { useI18n } from '@open-pencil/vue' +import { recoveryEnabled, setRecoveryEnabled } from '@/app/document/recovery/preferences' import { setSnappingPreference } from '@/app/settings/preferences/apply' import { appPreferences } from '@/app/settings/preferences/store' import AppSwitch from '@/components/ui/AppSwitch.vue' const { dialogs } = useI18n() +const preserveUnsavedWork = computed({ + get: () => recoveryEnabled.value, + set: setRecoveryEnabled +}) + const snapToGeometry = computed({ get: () => appPreferences.value.editing.snapping.geometry, set: (enabled: boolean) => setSnappingPreference('geometry', enabled) @@ -26,6 +32,27 @@ const snapToPixelGrid = computed({