Move browser extension sync to background script (#1617)

This commit is contained in:
Leendert de Borst
2026-02-04 10:23:28 +01:00
committed by Leendert de Borst
parent 7e433fe80a
commit 25c76280de
6 changed files with 542 additions and 408 deletions

View File

@@ -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());

View File

@@ -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<number> {
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<FullVaultSyncResult> {
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<VaultResponse>('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 };
}
}

View File

@@ -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<number | null>(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<void> => {
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 (
<div className="flex items-center gap-1.5 px-2 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-md text-xs font-medium">
<button
onClick={handleRetry}
disabled={isRetrying}
className="flex items-center gap-1.5 px-2 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-md text-xs font-medium cursor-pointer hover:opacity-80 active:opacity-60 transition-colors"
title={t('sync.tapToRetry')}
>
<div className="relative">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414"
/>
</svg>
{dbContext.isDirty && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-red-500 rounded-full" />
{isRetrying ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414"
/>
</svg>
{dbContext.isDirty && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-red-500 rounded-full" />
)}
</>
)}
</div>
<span>{t('common.offline')}</span>
</div>
<span>{t('sync.offline')}</span>
</button>
);
}
// 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 (
<div title={t('common.syncingVault')} className="flex items-center gap-1.5 px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-md text-xs font-medium">
<div
className="flex items-center gap-1.5 px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-md text-xs font-medium"
title={t('common.syncingVault')}
>
<svg className="w-3.5 h-3.5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
@@ -59,22 +178,38 @@ const ServerSyncIndicator: React.FC = () => {
);
}
// Show pending sync indicator (local changes waiting to be uploaded)
// Priority 3: Pending indicator (clickable to force sync) - icon only
if (dbContext.isDirty) {
return (
<div title={t('common.pendingSync')} className="flex items-center gap-1.5 px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded-md text-xs font-medium">
<div className="relative">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button
onClick={handleRetry}
disabled={isRetrying}
className="flex items-center gap-1.5 px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded-md text-xs font-medium cursor-pointer hover:opacity-80 active:opacity-60 transition-colors"
title={t('sync.tapToRetry')}
>
{isRetrying ? (
<svg className="w-3.5 h-3.5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-blue-500 dark:bg-blue-400 rounded-full animate-pulse" />
</div>
</div>
) : (
<div className="relative">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-blue-500 dark:bg-blue-400 rounded-full animate-pulse" />
</div>
)}
</button>
);
}

View File

@@ -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<void>) => Promise<void>;
} {
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<void> => {
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,

View File

@@ -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<boolean> => {
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<boolean> => {
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<VaultResponse>('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 };
};

View File

@@ -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"
}
}