From 25c76280def5e40db64a702ee7f84b5ccf1e8e9e Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 4 Feb 2026 10:23:28 +0100 Subject: [PATCH] Move browser extension sync to background script (#1617) --- .../src/entrypoints/background.ts | 3 +- .../background/VaultMessageHandler.ts | 296 ++++++++++++++ .../components/Layout/ServerSyncIndicator.tsx | 191 +++++++-- .../entrypoints/popup/hooks/useVaultMutate.ts | 78 ++-- .../entrypoints/popup/hooks/useVaultSync.ts | 378 +++--------------- .../src/i18n/locales/en.json | 4 + 6 files changed, 542 insertions(+), 408 deletions(-) diff --git a/apps/browser-extension/src/entrypoints/background.ts b/apps/browser-extension/src/entrypoints/background.ts index 8164588f9..2f45a4ae4 100644 --- a/apps/browser-extension/src/entrypoints/background.ts +++ b/apps/browser-extension/src/entrypoints/background.ts @@ -9,7 +9,7 @@ import { handleClipboardCopied, handleCancelClipboardClear, handleGetClipboardCl import { setupContextMenus } from '@/entrypoints/background/ContextMenu'; import { handleGetWebAuthnSettings, handleWebAuthnCreate, handleWebAuthnGet, handlePasskeyPopupResponse, handleGetRequestData } from '@/entrypoints/background/PasskeyHandler'; import { handleOpenPopup, handlePopupWithItem, handleOpenPopupCreateCredential, handleToggleContextMenu } from '@/entrypoints/background/PopupMessageHandler'; -import { handleCheckAuthStatus, handleClearPersistedFormValues, handleClearSession, handleClearVaultData, handleLockVault, handleCreateItem, handleGetFilteredItems, handleGetSearchItems, handleGetDefaultEmailDomain, handleGetDefaultIdentitySettings, handleGetEncryptionKey, handleGetEncryptionKeyDerivationParams, handleGetPasswordSettings, handleGetPersistedFormValues, handleGetVault, handlePersistFormValues, handleStoreEncryptionKey, handleStoreEncryptionKeyDerivationParams, handleStoreVaultMetadata, handleSyncVault, handleUploadVault, handleGetEncryptedVault, handleStoreEncryptedVault, handleGetSyncState, handleMarkVaultClean, handleGetServerRevision } from '@/entrypoints/background/VaultMessageHandler'; +import { handleCheckAuthStatus, handleClearPersistedFormValues, handleClearSession, handleClearVaultData, handleLockVault, handleCreateItem, handleGetFilteredItems, handleGetSearchItems, handleGetDefaultEmailDomain, handleGetDefaultIdentitySettings, handleGetEncryptionKey, handleGetEncryptionKeyDerivationParams, handleGetPasswordSettings, handleGetPersistedFormValues, handleGetVault, handlePersistFormValues, handleStoreEncryptionKey, handleStoreEncryptionKeyDerivationParams, handleStoreVaultMetadata, handleSyncVault, handleUploadVault, handleGetEncryptedVault, handleStoreEncryptedVault, handleGetSyncState, handleMarkVaultClean, handleGetServerRevision, handleFullVaultSync } from '@/entrypoints/background/VaultMessageHandler'; import { EncryptionKeyDerivationParams } from "@/utils/dist/core/models/metadata"; import { LocalPreferencesService } from '@/utils/LocalPreferencesService'; @@ -47,6 +47,7 @@ export default defineBackground({ onMessage('CREATE_ITEM', ({ data }) => handleCreateItem(data)); onMessage('UPLOAD_VAULT', () => handleUploadVault()); onMessage('SYNC_VAULT', () => handleSyncVault()); + onMessage('FULL_VAULT_SYNC', () => handleFullVaultSync()); onMessage('LOCK_VAULT', () => handleLockVault()); onMessage('CLEAR_SESSION', () => handleClearSession()); onMessage('CLEAR_VAULT_DATA', () => handleClearVaultData()); diff --git a/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts b/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts index ba9b8fc21..747ebbebd 100644 --- a/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts +++ b/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts @@ -7,6 +7,7 @@ import type { Vault, VaultResponse, VaultPostResponse } from '@/utils/dist/core/ import { EncryptionUtility } from '@/utils/EncryptionUtility'; import { SqliteClient } from '@/utils/SqliteClient'; import { getItemWithFallback } from '@/utils/StorageUtility'; +import { NetworkError } from '@/utils/types/errors/NetworkError'; import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError'; import { BoolResponse as messageBoolResponse } from '@/utils/types/messaging/BoolResponse'; import { IdentitySettingsResponse } from '@/utils/types/messaging/IdentitySettingsResponse'; @@ -15,6 +16,7 @@ import { PasswordSettingsResponse as messagePasswordSettingsResponse } from '@/u import { StringResponse as stringResponse } from '@/utils/types/messaging/StringResponse'; import { VaultResponse as messageVaultResponse } from '@/utils/types/messaging/VaultResponse'; import { VaultUploadResponse as messageVaultUploadResponse } from '@/utils/types/messaging/VaultUploadResponse'; +import { vaultMergeService } from '@/utils/VaultMergeService'; import { WebApiService } from '@/utils/WebApiService'; import { t } from '@/i18n/StandaloneI18n'; @@ -876,3 +878,297 @@ export async function handleGetServerRevision(): Promise { return revision ?? 0; } + +/** + * Result of a full vault sync operation. + */ +export type FullVaultSyncResult = { + success: boolean; + /** True if a new vault was downloaded from server */ + hasNewVault: boolean; + /** True if entered offline mode */ + wasOffline: boolean; + /** True if vault upgrade is required */ + upgradeRequired: boolean; + /** Error message if sync failed */ + error?: string; + /** Error key for translation (e.g. 'clientVersionNotSupported') */ + errorKey?: string; + /** True if user needs to be logged out */ + requiresLogout: boolean; +}; + +/** + * Full vault sync orchestration that runs entirely in background context. + * This ensures sync completes even if popup closes mid-operation. + * + * Sync logic: + * - If server has newer vault AND we have local changes (isDirty) → merge then upload + * - If server has newer vault AND no local changes → just download + * - If server has same revision AND we have local changes → upload + * - If offline → keep local changes, sync later + * + * Race detection: + * - Upload captures mutationSequence at start + * - After upload, only clears isDirty if sequence unchanged + * - If sequence changed during upload, stays dirty for next sync + */ +export async function handleFullVaultSync(): Promise { + const webApi = new WebApiService(); + + try { + // Check if user is logged in + const authStatus = await handleCheckAuthStatus(); + if (!authStatus.isLoggedIn) { + return { success: false, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: false }; + } + + if (authStatus.isVaultLocked) { + return { success: false, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: false, error: await t('common.errors.vaultIsLocked') }; + } + + // Check app status and vault revision + const statusResponse = await webApi.getStatus(); + + // Get current sync state + const syncState = await handleGetSyncState(); + + // Check if server is actually available (0.0.0 indicates connection error) + if (statusResponse.serverVersion === '0.0.0') { + // Server is unavailable - enter offline mode if we have a local vault + const encryptedVault = await storage.getItem('local:encryptedVault'); + if (encryptedVault) { + await storage.setItem('local:isOfflineMode', true); + return { success: true, hasNewVault: false, wasOffline: true, upgradeRequired: false, requiresLogout: false }; + } else { + return { success: false, hasNewVault: false, wasOffline: true, upgradeRequired: false, requiresLogout: false, error: await t('common.errors.serverNotAvailable') }; + } + } + + // Validate status response + const statusError = webApi.validateStatusResponse(statusResponse); + if (statusError) { + if (statusError === 'clientVersionNotSupported' || statusError === 'serverVersionNotSupported') { + return { success: false, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: true, errorKey: statusError }; + } + return { success: false, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: false, errorKey: statusError }; + } + + // Check if the SRP salt has changed (password change detection) + const storedEncryptionParams = await handleGetEncryptionKeyDerivationParams(); + if (storedEncryptionParams && statusResponse.srpSalt && statusResponse.srpSalt !== storedEncryptionParams.salt) { + return { success: false, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: true, errorKey: 'passwordChanged' }; + } + + // Valid connection - exit offline mode if we were in it + const isOffline = await storage.getItem('local:isOfflineMode') as boolean | null; + if (isOffline) { + await storage.setItem('local:isOfflineMode', false); + } + + const encryptionKey = await handleGetEncryptionKey(); + if (!encryptionKey) { + return { success: false, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: false, error: await t('common.errors.vaultIsLocked') }; + } + + if (statusResponse.vaultRevision > syncState.serverRevision) { + /* + * Server has a newer vault. + */ + const vaultResponseJson = await webApi.get('Vault'); + + try { + if (syncState.isDirty) { + /* + * We have local changes AND server has newer vault. + * Merge local vault with server vault, then upload the merged result. + */ + const localEncryptedVault = await storage.getItem('local:encryptedVault') as string | null; + + if (localEncryptedVault) { + const localDecrypted = await EncryptionUtility.symmetricDecrypt(localEncryptedVault, encryptionKey); + const serverDecrypted = await EncryptionUtility.symmetricDecrypt(vaultResponseJson.vault.blob, encryptionKey); + + const mergeResult = await vaultMergeService.merge(localDecrypted, serverDecrypted); + + if (mergeResult.success) { + console.info('Vault merge during sync completed:', mergeResult.stats); + + const mergedEncryptedVault = await EncryptionUtility.symmetricEncrypt( + mergeResult.mergedVaultBase64, + encryptionKey + ); + + /* + * Store merged vault. Use expectedMutationSeq to detect if a local mutation + * happened during merge - if so, reject and re-sync. + */ + const storeResult = await handleStoreEncryptedVault({ + vaultBlob: mergedEncryptedVault, + serverRevision: vaultResponseJson.vault.currentRevisionNumber, + expectedMutationSeq: syncState.mutationSequence + }); + + if (!storeResult.success) { + console.info('Mutation detected during merge, re-syncing...'); + return handleFullVaultSync(); + } + + // Upload merged vault to server + const uploadResponse = await handleUploadVault(); + + if (uploadResponse.success && uploadResponse.status === 0) { + await handleMarkVaultClean({ + mutationSeqAtStart: uploadResponse.mutationSeqAtStart!, + newServerRevision: uploadResponse.newRevisionNumber! + }); + } else if (uploadResponse.status === 2) { + // Server returned Outdated - another device uploaded. Re-sync. + return handleFullVaultSync(); + } else { + console.error('Failed to upload merged vault:', uploadResponse.error); + return { success: false, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: false, error: uploadResponse.error }; + } + + // Store metadata + await handleStoreVaultMetadata({ + publicEmailDomainList: vaultResponseJson.vault.publicEmailDomainList, + privateEmailDomainList: vaultResponseJson.vault.privateEmailDomainList, + hiddenPrivateEmailDomainList: vaultResponseJson.vault.hiddenPrivateEmailDomainList, + }); + + // Check for pending migrations + const sqliteClient = await createVaultSqliteClient(); + const hasPendingMigrations = await sqliteClient.hasPendingMigrations(); + + return { success: true, hasNewVault: true, wasOffline: false, upgradeRequired: hasPendingMigrations, requiresLogout: false }; + } else { + console.error('Vault merge failed during sync, using server vault'); + // Fall through to use server vault + } + } + } + + /* + * No local changes (or merge failed) - just use server vault. + * Use expectedMutationSeq to detect concurrent mutations. + */ + const storeResult = await handleStoreEncryptedVault({ + vaultBlob: vaultResponseJson.vault.blob, + serverRevision: vaultResponseJson.vault.currentRevisionNumber, + expectedMutationSeq: syncState.mutationSequence + }); + + if (!storeResult.success) { + console.info('Mutation detected during sync, re-syncing...'); + return handleFullVaultSync(); + } + + await handleStoreVaultMetadata({ + publicEmailDomainList: vaultResponseJson.vault.publicEmailDomainList, + privateEmailDomainList: vaultResponseJson.vault.privateEmailDomainList, + hiddenPrivateEmailDomainList: vaultResponseJson.vault.hiddenPrivateEmailDomainList, + }); + + // Check for pending migrations + const sqliteClient = await createVaultSqliteClient(); + const hasPendingMigrations = await sqliteClient.hasPendingMigrations(); + + return { success: true, hasNewVault: true, wasOffline: false, upgradeRequired: hasPendingMigrations, requiresLogout: false }; + } catch (error) { + if (error instanceof VaultVersionIncompatibleError) { + return { success: false, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: true, error: error.message }; + } + throw new Error('Vault could not be decrypted, if the problem persists please logout and login again.'); + } + } else if (statusResponse.vaultRevision === syncState.serverRevision) { + /** + * Server and local vault are at the same revision. + * If we have pending local changes, upload them now. + */ + if (syncState.isDirty) { + const uploadResponse = await handleUploadVault(); + if (uploadResponse.success && uploadResponse.status === 0) { + await handleMarkVaultClean({ + mutationSeqAtStart: uploadResponse.mutationSeqAtStart!, + newServerRevision: uploadResponse.newRevisionNumber! + }); + } else if (uploadResponse.status === 2) { + /** + * Server returned Outdated - another device uploaded first. + * Recursively call sync to fetch, merge, and retry. + */ + return handleFullVaultSync(); + } else { + console.error('Failed to upload pending vault:', uploadResponse.error); + return { success: false, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: false, error: uploadResponse.error }; + } + } + + return { success: true, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: false }; + } else if (statusResponse.vaultRevision < syncState.serverRevision) { + /** + * Server revision DECREASED - server data loss/rollback detected. + * Client has more advanced revision - upload to recover server state. + */ + console.warn( + `Server data loss detected! Server at rev ${statusResponse.vaultRevision}, ` + + `client at rev ${syncState.serverRevision}. Uploading to recover server state.` + ); + + const uploadResponse = await handleUploadVault(); + + if (uploadResponse.success && uploadResponse.status === 0) { + await handleMarkVaultClean({ + mutationSeqAtStart: uploadResponse.mutationSeqAtStart!, + newServerRevision: uploadResponse.newRevisionNumber! + }); + + console.info( + `Server recovery complete: rev ${statusResponse.vaultRevision} → ${uploadResponse.newRevisionNumber}` + ); + + return { success: true, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: false }; + } else if (uploadResponse.status === 2) { + // Another client recovered first + console.info('Another client recovered server first, re-syncing...'); + return handleFullVaultSync(); + } else { + console.error('Server recovery failed:', uploadResponse.error); + return { success: false, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: false, error: await t('common.errors.unknownError') }; + } + } + + // Check for pending migrations (for paths that didn't initialize a new database) + try { + const sqliteClient = await createVaultSqliteClient(); + const hasPendingMigrations = await sqliteClient.hasPendingMigrations(); + if (hasPendingMigrations) { + return { success: true, hasNewVault: false, wasOffline: false, upgradeRequired: true, requiresLogout: false }; + } + } catch { + // Ignore errors checking migrations + } + + return { success: true, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: false }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error during vault sync'; + console.error('Vault sync error:', err); + + // Check if it's a version-related error + if (err instanceof VaultVersionIncompatibleError) { + return { success: false, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: true, error: errorMessage }; + } + + // Check if it's a network error - enter offline mode + if (err instanceof NetworkError) { + const encryptedVault = await storage.getItem('local:encryptedVault'); + if (encryptedVault) { + await storage.setItem('local:isOfflineMode', true); + return { success: true, hasNewVault: false, wasOffline: true, upgradeRequired: false, requiresLogout: false }; + } + } + + return { success: false, hasNewVault: false, wasOffline: false, upgradeRequired: false, requiresLogout: false, error: errorMessage }; + } +} diff --git a/apps/browser-extension/src/entrypoints/popup/components/Layout/ServerSyncIndicator.tsx b/apps/browser-extension/src/entrypoints/popup/components/Layout/ServerSyncIndicator.tsx index 0b548a968..61cdbf096 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Layout/ServerSyncIndicator.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Layout/ServerSyncIndicator.tsx @@ -1,52 +1,171 @@ -import React from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { sendMessage } from 'webext-bridge/popup'; +import type { FullVaultSyncResult } from '@/entrypoints/background/VaultMessageHandler'; import { useApp } from '@/entrypoints/popup/context/AppContext'; import { useDb } from '@/entrypoints/popup/context/DbContext'; +/** + * Minimum time (ms) to show the syncing indicator. + * Ensures user sees confirmation that a new vault was downloaded. + */ +const MIN_SYNC_DISPLAY_TIME = 1000; + /** * Sync status indicator component. - * Displays status badges for offline mode and pending sync. + * Displays clickable status badges for offline mode, syncing, and pending sync. + * + * Priority order (highest to lowest): + * 1. Offline (amber) - network unavailable, clickable to retry + * 2. Syncing (green spinner) - downloading new vault (minimum display time) + * 3. Pending (blue) - local changes waiting to be uploaded, clickable to retry + * 4. Hidden - when synced + * + * Note: The syncing indicator only appears when actually downloading a new vault, + * not during routine checks where nothing changed. */ const ServerSyncIndicator: React.FC = () => { const { t } = useTranslation(); const app = useApp(); const dbContext = useDb(); + const [isRetrying, setIsRetrying] = useState(false); + + // Track syncing state with minimum display time + const [showSyncing, setShowSyncing] = useState(false); + const syncStartTimeRef = useRef(null); + + /** + * Handle syncing state changes with minimum display time. + * When syncing starts, show indicator immediately. + * When syncing ends, wait until minimum time has passed. + */ + useEffect(() => { + if (dbContext.isSyncing) { + // Sync started - show immediately and record start time + setShowSyncing(true); + syncStartTimeRef.current = Date.now(); + } else if (syncStartTimeRef.current !== null) { + // Sync ended - wait for minimum display time + const elapsed = Date.now() - syncStartTimeRef.current; + const remaining = MIN_SYNC_DISPLAY_TIME - elapsed; + + if (remaining > 0) { + const timer = setTimeout((): void => { + setShowSyncing(false); + syncStartTimeRef.current = null; + }, remaining); + return (): void => { + clearTimeout(timer); + }; + } else { + setShowSyncing(false); + syncStartTimeRef.current = null; + } + } + }, [dbContext.isSyncing]); + + /** + * Handle tap to force sync retry. + */ + const handleRetry = useCallback(async (): Promise => { + if (isRetrying) { + return; + } + + setIsRetrying(true); + + try { + const result = await sendMessage('FULL_VAULT_SYNC', {}, 'background') as FullVaultSyncResult; + + // Handle logout requirement + if (result.requiresLogout) { + const errorMessage = result.errorKey + ? t('common.errors.' + result.errorKey) + : result.error; + await app.logout(errorMessage); + return; + } + + // Update offline state based on result + if (result.wasOffline) { + await dbContext.setIsOffline(true); + } else if (dbContext.isOffline) { + // We were offline but now succeeded + await dbContext.setIsOffline(false); + } + + // Reload database if we got a new vault + if (result.hasNewVault) { + await dbContext.loadStoredDatabase(); + } + + await dbContext.refreshSyncState(); + } catch (error) { + console.error('Retry sync error:', error); + } finally { + setIsRetrying(false); + } + }, [isRetrying, dbContext, app, t]); /* * Only show when logged in AND vault is unlocked (dbAvailable). - * When vault is locked, we can't sync anyway, so showing "Syncing..." is misleading. + * When vault is locked, we can't sync anyway, so showing indicator is misleading. */ if (!app.isLoggedIn || !dbContext.dbAvailable) { return null; } - // Show offline indicator (with badge dot if there are pending changes) + // Priority 1: Offline indicator (clickable to retry) - keep text for important context if (dbContext.isOffline) { return ( -
+
+ {t('sync.offline')} + ); } - // Show syncing indicator (downloading new vault from server) - if (dbContext.isSyncing) { + /* + * Priority 2: Syncing indicator (not clickable, shows progress) + * Only shown when actually downloading a new vault, with minimum display time + */ + if (showSyncing) { return ( -
+
{ ); } - // Show pending sync indicator (local changes waiting to be uploaded) + // Priority 3: Pending indicator (clickable to force sync) - icon only if (dbContext.isDirty) { return ( -
-
- + -
-
+ ) : ( +
+ + + + +
+ )} + ); } diff --git a/apps/browser-extension/src/entrypoints/popup/hooks/useVaultMutate.ts b/apps/browser-extension/src/entrypoints/popup/hooks/useVaultMutate.ts index 4d8ace0c9..4ef05c6ff 100644 --- a/apps/browser-extension/src/entrypoints/popup/hooks/useVaultMutate.ts +++ b/apps/browser-extension/src/entrypoints/popup/hooks/useVaultMutate.ts @@ -1,12 +1,10 @@ -import { useCallback, useRef } from 'react'; +import { useCallback } from 'react'; import { sendMessage } from 'webext-bridge/popup'; import { useDb } from '@/entrypoints/popup/context/DbContext'; import { EncryptionUtility } from '@/utils/EncryptionUtility'; -import { useVaultSync } from './useVaultSync'; - /** * Hook to execute a vault mutation. * @@ -19,15 +17,14 @@ import { useVaultSync } from './useVaultSync'; * - Each mutation increments the sequence * - Sync captures sequence at start, only clears dirty if sequence unchanged * - This ensures we never lose local changes during concurrent operations + * + * The sync is truly fire-and-forget: it runs in the background script and continues + * even if the popup closes. This ensures vault changes are always synced to the server. */ export function useVaultMutate(): { executeVaultMutationAsync: (operation: () => Promise) => Promise; } { const dbContext = useDb(); - const { syncVault } = useVaultSync(); - - // Track if a sync is currently in progress - const isSyncingRef = useRef(false); /** * Execute the provided operation and save locally. @@ -56,52 +53,23 @@ export function useVaultMutate(): { }, [dbContext]); /** - * Trigger a sync cycle. If sync is already in progress, it will be queued. - * After sync completes, checks if more mutations happened and re-syncs if needed. + * Trigger a sync in the background script. + * This is fire-and-forget - the sync runs entirely in the background context + * and continues even if the popup closes. */ - const triggerSync = useCallback(async (): Promise => { - if (isSyncingRef.current) { - // Sync already in progress - it will re-sync if dirty when done - return; - } - - isSyncingRef.current = true; - - try { - await syncVault({ - /** - * Handle successful sync completion. - */ - onSuccess: async () => { - await dbContext.refreshSyncState(); - - // Skip re-sync if offline - vault stays dirty until server is reachable - if (!dbContext.getIsOffline()) { - const syncState = await sendMessage('GET_SYNC_STATE', {}, 'background') as { isDirty: boolean }; - if (syncState.isDirty) { - isSyncingRef.current = false; - await triggerSync(); - } - } - }, - /** - * Offline mode - no re-sync needed, vault stays dirty until online. - */ - onOffline: () => {}, - /** - * Handle sync errors. - * @param error - Error message from sync - */ - onError: (error) => { - console.error('Background sync error:', error); - } - }); - } catch (error) { - console.error('Error during background sync:', error); - } finally { - isSyncingRef.current = false; - } - }, [dbContext, syncVault]); + const triggerBackgroundSync = useCallback((): void => { + /* + * Fire-and-forget: send message to background without awaiting. + * The background script will handle the full sync orchestration + * and will re-sync if mutations happened during the sync. + */ + sendMessage('FULL_VAULT_SYNC', {}, 'background').then(async () => { + // Refresh sync state if popup is still open + await dbContext.refreshSyncState(); + }).catch((error) => { + console.error('Background sync error:', error); + }); + }, [dbContext]); /** * Execute a vault mutation asynchronously: save locally immediately, then @@ -113,9 +81,9 @@ export function useVaultMutate(): { // 1. Execute mutation and save locally (fast, doesn't block) await saveLocally(operation); - // 2. Trigger sync in background - void triggerSync(); - }, [saveLocally, triggerSync]); + // 2. Trigger sync in background (fire-and-forget, continues even if popup closes) + triggerBackgroundSync(); + }, [saveLocally, triggerBackgroundSync]); return { executeVaultMutationAsync, diff --git a/apps/browser-extension/src/entrypoints/popup/hooks/useVaultSync.ts b/apps/browser-extension/src/entrypoints/popup/hooks/useVaultSync.ts index 5f5c4074b..8e96828f1 100644 --- a/apps/browser-extension/src/entrypoints/popup/hooks/useVaultSync.ts +++ b/apps/browser-extension/src/entrypoints/popup/hooks/useVaultSync.ts @@ -2,18 +2,9 @@ import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { sendMessage } from 'webext-bridge/popup'; +import type { FullVaultSyncResult } from '@/entrypoints/background/VaultMessageHandler'; import { useApp } from '@/entrypoints/popup/context/AppContext'; import { useDb } from '@/entrypoints/popup/context/DbContext'; -import { useWebApi } from '@/entrypoints/popup/context/WebApiContext'; - -import type { EncryptionKeyDerivationParams } from '@/utils/dist/core/models/metadata'; -import type { VaultResponse } from '@/utils/dist/core/models/webapi'; -import { EncryptionUtility } from '@/utils/EncryptionUtility'; -import { ApiAuthError } from '@/utils/types/errors/ApiAuthError'; -import { NetworkError } from '@/utils/types/errors/NetworkError'; -import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError'; -import type { VaultUploadResponse } from '@/utils/types/messaging/VaultUploadResponse'; -import { vaultMergeService } from '@/utils/VaultMergeService'; type VaultSyncOptions = { onSuccess?: (hasNewVault: boolean) => void; @@ -25,15 +16,16 @@ type VaultSyncOptions = { /** * Hook to sync the vault with the server. - * Supports offline mode: if server is unavailable, continues with local vault. + * Delegates to background script for actual sync orchestration. + * This ensures sync completes even if popup closes mid-operation. * - * Sync logic: + * Sync logic (handled in background): * - If server has newer vault AND we have local changes (isDirty) → merge then upload * - If server has newer vault AND no local changes → just download * - If server has same revision AND we have local changes → upload * - If offline → keep local changes, sync later * - * Race detection: + * Race detection (handled in background): * - Upload captures mutationSequence at start * - After upload, only clears isDirty if sequence unchanged * - If sequence changed during upload, stays dirty for next sync @@ -42,346 +34,84 @@ export const useVaultSync = (): { syncVault: (options?: VaultSyncOptions) => Pro const { t } = useTranslation(); const app = useApp(); const dbContext = useDb(); - const webApi = useWebApi(); - - /** - * Check for pending migrations and trigger upgrade if needed. - * @returns True if upgrade is required (caller should return), false otherwise. - */ - const checkAndHandleUpgrade = useCallback(async (onUpgradeRequired?: () => void): Promise => { - if (await dbContext.hasPendingMigrations()) { - onUpgradeRequired?.(); - return true; - } - return false; - }, [dbContext]); - - /** - * Handle entering offline mode. - * @returns True to indicate success (caller should return true). - */ - const enterOfflineMode = useCallback(async (onStatus?: (message: string) => void, onOffline?: () => void, onSuccess?: (hasNewVault: boolean) => void): Promise => { - await dbContext.setIsOffline(true); - onStatus?.(t('common.offlineMode')); - onOffline?.(); - onSuccess?.(false); - return true; - }, [dbContext, t]); const syncVault = useCallback(async (options: VaultSyncOptions = {}) => { const { onSuccess, onError, onStatus, onOffline, onUpgradeRequired } = options; try { + // Check if user is logged in first const isLoggedIn = await app.initializeAuth(); if (!isLoggedIn) { - // Not authenticated, return false immediately return false; } - // Check app status and vault revision + // Show checking status (don't show syncing indicator yet - only if server has new vault) onStatus?.(t('common.checkingVaultUpdates')); - const statusResponse = await webApi.getStatus(); - // Get current sync state to check if server has newer vault - const syncState = await sendMessage('GET_SYNC_STATE', {}, 'background') as { - isDirty: boolean; - mutationSequence: number; - serverRevision: number; - }; + // Delegate to background script for full sync orchestration + const result = await sendMessage('FULL_VAULT_SYNC', {}, 'background') as FullVaultSyncResult; - // Set syncing indicator if server has newer vault (actual sync will happen) - if (statusResponse.vaultRevision > syncState.serverRevision) { - dbContext.setIsSyncing(true); - } - - // Check if server is actually available, 0.0.0 indicates connection error which triggers offline mode. - if (statusResponse.serverVersion === '0.0.0') { - // Server is unavailable - enter offline mode if we have a local vault - if (dbContext.dbAvailable) { - return enterOfflineMode(onStatus, onOffline, onSuccess); - } else { - // No local vault available, can't operate offline - onError?.(t('common.errors.serverNotAvailable')); - return false; - } - } - - const statusError = webApi.validateStatusResponse(statusResponse); - if (statusError) { - // Version compatibility errors require logout - if (statusError === 'clientVersionNotSupported' || statusError === 'serverVersionNotSupported') { - await app.logout(t('common.errors.' + statusError)); - return false; - } - // Other errors just show the error - onError?.(t('common.errors.' + statusError)); - return false; - } - - // Check if the SRP salt has changed compared to locally stored encryption key derivation params - const storedEncryptionParams = await sendMessage('GET_ENCRYPTION_KEY_DERIVATION_PARAMS', {}, 'background') as EncryptionKeyDerivationParams | null; - if (storedEncryptionParams && statusResponse.srpSalt && statusResponse.srpSalt !== storedEncryptionParams.salt) { - /** - * Server SRP salt has changed compared to locally stored value, which means the user has changed - * their password since the last time they logged in. This means that the local encryption key is no - * longer valid and the user needs to re-authenticate. We trigger a logout but do not revoke tokens - * as these were already revoked by the server upon password change. - */ - await app.logout(t('common.errors.passwordChanged')); - return false; - } - - // We have a valid connection to the server - exit offline mode if we were in it - if (dbContext.isOffline) { - await dbContext.setIsOffline(false); - } - - if (statusResponse.vaultRevision > syncState.serverRevision) { - /* - * Server has a newer vault. - */ - onStatus?.(t('common.syncingUpdatedVault')); - const vaultResponseJson = await webApi.get('Vault'); - - try { - const encryptionKey = await sendMessage('GET_ENCRYPTION_KEY', {}, 'background') as string; - - if (syncState.isDirty) { - /* - * We have local changes AND server has newer vault. - * Merge local vault with server vault, then upload the merged result. - */ - onStatus?.(t('common.mergingVaultChanges')); - const localEncryptedVault = await sendMessage('GET_ENCRYPTED_VAULT', {}, 'background') as string | null; - - if (localEncryptedVault) { - const localDecrypted = await EncryptionUtility.symmetricDecrypt(localEncryptedVault, encryptionKey); - const serverDecrypted = await EncryptionUtility.symmetricDecrypt(vaultResponseJson.vault.blob, encryptionKey); - - const mergeResult = await vaultMergeService.merge(localDecrypted, serverDecrypted); - - if (mergeResult.success) { - console.info('Vault merge during sync completed:', mergeResult.stats); - - const mergedEncryptedVault = await EncryptionUtility.symmetricEncrypt( - mergeResult.mergedVaultBase64, - encryptionKey - ); - - /* - * Store merged vault. Use expectedMutationSeq to detect if a local mutation - * happened during merge - if so, reject and re-sync. - */ - const storeResult = await sendMessage('STORE_ENCRYPTED_VAULT', { - vaultBlob: mergedEncryptedVault, - serverRevision: vaultResponseJson.vault.currentRevisionNumber, - expectedMutationSeq: syncState.mutationSequence - }, 'background') as { success: boolean; mutationSequence: number }; - - if (!storeResult.success) { - console.info('Mutation detected during merge, re-syncing...'); - return syncVault(options); - } - - // Upload merged vault to server - onStatus?.(t('common.uploadingVault')); - const uploadResponse = await sendMessage('UPLOAD_VAULT', {}, 'background') as VaultUploadResponse; - - if (uploadResponse.success && uploadResponse.status === 0) { - await sendMessage('MARK_VAULT_CLEAN', { - mutationSeqAtStart: uploadResponse.mutationSeqAtStart, - newServerRevision: uploadResponse.newRevisionNumber - }, 'background'); - } else if (uploadResponse.status === 2) { - // Server returned Outdated - another device uploaded. Re-sync. - return syncVault(options); - } else { - /* - * Upload failed (e.g. server returned 500). Report error to prevent - * the caller from re-checking isDirty and looping immediately. - */ - console.error('Failed to upload merged vault:', uploadResponse.error); - onError?.(uploadResponse.error ?? t('common.errors.unknownError')); - return false; - } - - // Store metadata and load merged vault into memory - await sendMessage('STORE_VAULT_METADATA', { - publicEmailDomainList: vaultResponseJson.vault.publicEmailDomainList, - privateEmailDomainList: vaultResponseJson.vault.privateEmailDomainList, - hiddenPrivateEmailDomainList: vaultResponseJson.vault.hiddenPrivateEmailDomainList, - }, 'background'); - - await dbContext.loadDatabase(mergeResult.mergedVaultBase64); - await dbContext.refreshSyncState(); - - if (await checkAndHandleUpgrade(onUpgradeRequired)) { - return false; - } - - onSuccess?.(true); - return true; - } else { - console.error('Vault merge failed during sync, using server vault'); - // Fall through to use server vault - } - } - } - - /* - * No local changes (or merge failed) - just use server vault. - * Use expectedMutationSeq to detect concurrent mutations. - */ - const storeResult = await sendMessage('STORE_ENCRYPTED_VAULT', { - vaultBlob: vaultResponseJson.vault.blob, - serverRevision: vaultResponseJson.vault.currentRevisionNumber, - expectedMutationSeq: syncState.mutationSequence - }, 'background') as { success: boolean; mutationSequence: number }; - - if (!storeResult.success) { - console.info('Mutation detected during sync, re-syncing...'); - return syncVault(options); - } - - await sendMessage('STORE_VAULT_METADATA', { - publicEmailDomainList: vaultResponseJson.vault.publicEmailDomainList, - privateEmailDomainList: vaultResponseJson.vault.privateEmailDomainList, - hiddenPrivateEmailDomainList: vaultResponseJson.vault.hiddenPrivateEmailDomainList, - }, 'background'); - - const decryptedVault = await EncryptionUtility.symmetricDecrypt(vaultResponseJson.vault.blob, encryptionKey); - await dbContext.loadDatabase(decryptedVault); - await dbContext.refreshSyncState(); - - if (await checkAndHandleUpgrade(onUpgradeRequired)) { - return false; - } - - onSuccess?.(true); - return true; - } catch (error) { - if (error instanceof VaultVersionIncompatibleError) { - await app.logout(error.message); - return false; - } - throw new Error('Vault could not be decrypted, if the problem persists please logout and login again.'); - } - } else if (statusResponse.vaultRevision === syncState.serverRevision) { - /** - * Server and local vault are at the same revision. - * If we have pending local changes, upload them now. - */ - if (syncState.isDirty) { - onStatus?.(t('common.uploadingVault')); - - // Vault is already stored locally, just upload it - const uploadResponse = await sendMessage('UPLOAD_VAULT', {}, 'background') as VaultUploadResponse; - if (uploadResponse.success && uploadResponse.status === 0) { - // Upload succeeded - try to clear dirty flag - await sendMessage('MARK_VAULT_CLEAN', { - mutationSeqAtStart: uploadResponse.mutationSeqAtStart, - newServerRevision: uploadResponse.newRevisionNumber - }, 'background'); - await dbContext.refreshSyncState(); - } else if (uploadResponse.status === 2) { - /** - * Server returned Outdated - another device uploaded first. - * Recursively call syncVault to fetch, merge, and retry. - */ - return syncVault(options); - } else { - /* - * Upload failed (e.g. server returned 500). Report error to prevent - * the caller from re-checking isDirty and looping immediately. - */ - console.error('Failed to upload pending vault:', uploadResponse.error); - onError?.(uploadResponse.error ?? t('common.errors.unknownError')); - return false; - } - - onSuccess?.(false); - return true; - } - } else if (statusResponse.vaultRevision < syncState.serverRevision) { - /** - * Server revision DECREASED - server data loss/rollback detected. - * Client has more advanced revision - upload to recover server state. - * - * This will create a revision gap in server history (e.g., 95 → 101), - * which serves as an audit trail of the recovery event. - */ - - console.warn( - `Server data loss detected! Server at rev ${statusResponse.vaultRevision}, ` + - `client at rev ${syncState.serverRevision}. Uploading to recover server state.` - ); - - onStatus?.(t('common.uploadingVault')); - - const uploadResponse = await sendMessage('UPLOAD_VAULT', {}, 'background') as VaultUploadResponse; - - if (uploadResponse.success && uploadResponse.status === 0) { - // Upload succeeded - await sendMessage('MARK_VAULT_CLEAN', { - mutationSeqAtStart: uploadResponse.mutationSeqAtStart, - newServerRevision: uploadResponse.newRevisionNumber - }, 'background'); - await dbContext.refreshSyncState(); - - console.info( - `Server recovery complete: rev ${statusResponse.vaultRevision} → ${uploadResponse.newRevisionNumber}` - ); - - onSuccess?.(false); - return true; - } else if (uploadResponse.status === 2) { - // Another client recovered first - console.info('Another client recovered server first, re-syncing...'); - return syncVault(options); - } else { - console.error('Server recovery failed:', uploadResponse.error); - onError?.(t('common.errors.unknownError')); - return false; - } - } - - // Check if upgrade is required (for paths that didn't initialize a new database) - if (await checkAndHandleUpgrade(onUpgradeRequired)) { - return false; - } - - onSuccess?.(false); - return false; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Unknown error during vault sync'; - console.error('Vault sync error:', err); - - // Check if it's a version-related error (app needs to be updated) - if (err instanceof VaultVersionIncompatibleError) { + // Handle logout requirement + if (result.requiresLogout) { + const errorMessage = result.errorKey + ? t('common.errors.' + result.errorKey) + : result.error; await app.logout(errorMessage); return false; } - // Check if it's an auth error (session expired) - logout is already triggered by WebApiService - if (err instanceof ApiAuthError) { + // Handle offline mode + if (result.wasOffline) { + await dbContext.setIsOffline(true); + onStatus?.(t('common.offlineMode')); + onOffline?.(); + onSuccess?.(false); + return true; + } + + // Exit offline mode if we were in it + if (dbContext.isOffline) { + await dbContext.setIsOffline(false); + } + + // Handle upgrade requirement + if (result.upgradeRequired) { + onUpgradeRequired?.(); return false; } - // Check if it's a network error - enter offline mode if we have a local vault - if (err instanceof NetworkError) { - if (dbContext.dbAvailable) { - return enterOfflineMode(onStatus, onOffline, onSuccess); - } + // Handle errors + if (!result.success) { + const errorMessage = result.errorKey + ? t('common.errors.' + result.errorKey) + : result.error ?? t('common.errors.unknownError'); + onError?.(errorMessage); + return false; } + // If we got a new vault, show syncing indicator and reload database into memory + if (result.hasNewVault) { + dbContext.setIsSyncing(true); + onStatus?.(t('common.syncingUpdatedVault')); + await dbContext.loadStoredDatabase(); + } + + // Refresh sync state from storage + await dbContext.refreshSyncState(); + + onSuccess?.(result.hasNewVault); + return true; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error during vault sync'; + console.error('Vault sync error:', err); onError?.(errorMessage); return false; } finally { // Always clear syncing state when done dbContext.setIsSyncing(false); } - }, [app, dbContext, webApi, t, checkAndHandleUpgrade, enterOfflineMode]); + }, [app, dbContext, t]); return { syncVault }; }; diff --git a/apps/browser-extension/src/i18n/locales/en.json b/apps/browser-extension/src/i18n/locales/en.json index d652757fe..1b4b20724 100644 --- a/apps/browser-extension/src/i18n/locales/en.json +++ b/apps/browser-extension/src/i18n/locales/en.json @@ -515,5 +515,9 @@ "unsyncedChangesTitle": "Unsynced Changes", "unsyncedChangesWarning": "You have unsynced changes that will be lost if you log out now. Are you sure you want to continue?", "logoutAnyway": "Log out anyway" + }, + "sync": { + "offline": "Offline", + "tapToRetry": "Tap to retry sync" } } \ No newline at end of file