mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-24 16:32:20 -04:00
Move browser extension sync to background script (#1617)
This commit is contained in:
committed by
Leendert de Borst
parent
7e433fe80a
commit
25c76280de
@@ -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());
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user