Files
NetMesh/infrastructure/services/CloudSyncManager.ts

1072 lines
35 KiB
TypeScript
Raw Normal View History

/**
* CloudSyncManager - Central Orchestrator for Multi-Cloud Sync
*
* Manages:
* - Security state machine (NO_KEY LOCKED UNLOCKED)
* - Sync state machine (IDLE SYNCING CONFLICT/ERROR)
* - Provider adapters (GitHub, Google, OneDrive)
* - Version conflict detection and resolution
* - Auto-sync scheduling
*/
import {
type CloudProvider,
type SecurityState,
type SyncState,
type SyncPayload,
type SyncResult,
type ConflictInfo,
type ConflictResolution,
type MasterKeyConfig,
type UnlockedMasterKey,
type ProviderConnection,
type RemoteSyncPayload,
type ProviderAccount,
type SyncEvent,
type SyncHistoryEntry,
type SyncSnapshotEntry,
type WebDAVConfig,
type S3Config,
type SyncedFile,
type ConvergentProviderBaselineV2,
type ConvergentReplicaRecordV2,
type ConvergentFieldConflict,
} from '../../domain/sync';
import type { CloudSyncConflictAction, CloudSyncStrategy } from '../../domain/syncStrategy';
import { materializeConvergentSyncState } from '../../domain/convergentSync';
import { type CloudAdapter } from './adapters';
import type { DeviceFlowState } from './adapters/GitHubAdapter';
import { clearConvergentSyncLocalConfigAfterDowngrade } from './convergentSyncConfig';
import { type ShrinkFinding } from '../../domain/syncGuards';
// Extracted into a plain ESM module so the signature logic is covered by
// the node --test harness (see syncSignature.test.mjs). The previous
// inline implementation only hashed a handful of meta fields and was
// trivially forgeable by a misbehaving adapter; v2 hashes the full meta
// plus a prefix of the ciphertext.
import {
startProviderAuthImpl,
completeGitHubAuthImpl,
completePKCEAuthImpl,
connectConfigProviderImpl,
connectPluginProviderImpl,
resetProviderStatusImpl,
setProviderErrorImpl,
clearConnectingStatusImpl,
clearProviderErrorImpl,
cancelProviderAuthAttemptImpl,
disconnectProviderImpl,
updateProviderStatusImpl,
isActiveAuthAttemptImpl,
buildAccountFromConfigImpl,
syncAnchorKeyImpl,
createSyncedFileSignatureImpl as createSyncedFileSignatureMethodImpl,
loadSyncAnchorImpl,
saveSyncAnchorImpl,
clearSyncAnchorImpl,
inspectProviderRemoteStateImpl,
checkProviderConflictImpl,
inspectProviderRemoteImpl,
commitRemoteInspectionImpl,
} from './cloudSync/authMethods';
import {
uploadToProviderImpl,
buildPayloadImpl,
syncToProviderImpl,
downloadFromProviderImpl,
getGistRevisionHistoryImpl,
downloadGistRevisionImpl,
resolveConflictImpl,
exitBlockedStateImpl,
clearShrinkBlockedStateImpl,
getShrinkBlockedFindingImpl,
} from './cloudSync/providerSyncMethods';
import {
syncAllProvidersImpl,
setDeviceNameImpl,
setAutoSyncImpl,
startAutoSyncImpl,
stopAutoSyncImpl,
saveSyncConfigImpl,
syncBaseKeyImpl,
syncSnapshotsKeyImpl,
providerAccountIdKeyImpl,
loadProviderAccountIdImpl,
saveProviderAccountIdImpl,
saveSyncBaseImpl,
loadSyncBaseImpl,
loadSyncSnapshotsImpl,
clearSyncBaseImpl,
addSyncHistoryEntryImpl,
resetLocalVersionImpl,
} from './cloudSync/syncAllStorageMethods';
import {
loadInitialStateImpl,
loadProviderConnectionImpl,
initProviderDecryptionImpl,
saveProviderConnectionImpl,
loadFromStorageImpl,
saveToStorageImpl,
removeFromStorageImpl,
setupCrossWindowSyncImpl,
safeJsonParseImpl,
handleStorageEventImpl,
getConnectedAdapterImpl,
setupMasterKeyImpl,
unlockImpl,
lockImpl,
changeMasterKeyImpl,
verifyPasswordImpl,
handleProviderReauthRequiredImpl,
setAvailablePluginSyncProviderIdsImpl,
} from './cloudSync/stateAndSecurityMethods';
import {
clearConvergentSyncStorageImpl,
convergentProviderBaselineKeyImpl,
loadConvergentProviderBaselineImpl,
loadConvergentReplicaImpl,
reencryptSyncStorageImpl,
saveConvergentProviderBaselineImpl,
saveConvergentReplicaImpl,
} from './cloudSync/convergentSyncStorageMethods';
import {
downgradeConvergentSyncImpl,
previewConvergentRecoveryImpl,
resolveConvergentConflictAndSyncImpl,
syncConvergentProvidersUnlockedImpl,
withConvergentSyncWebLock,
} from './cloudSync/convergentSyncRuntimeMethods';
// ============================================================================
// Types
// ============================================================================
export interface SyncManagerState {
securityState: SecurityState;
syncState: SyncState;
masterKeyConfig: MasterKeyConfig | null;
unlockedKey: UnlockedMasterKey | null;
providers: Record<CloudProvider, ProviderConnection>;
deviceId: string;
deviceName: string;
localVersion: number;
localUpdatedAt: number;
remoteVersion: number;
remoteUpdatedAt: number;
currentConflict: ConflictInfo | null;
lastError: string | null;
autoSyncEnabled: boolean;
autoSyncInterval: number;
syncStrategy: CloudSyncStrategy;
syncHistory: SyncHistoryEntry[];
/** True when local vault data differs from the last successful sync snapshot. */
pendingLocalSync: boolean;
/** Current field-level conflicts retained by convergent sync v2. */
convergentConflicts: ConvergentFieldConflict[];
/** Last shrink finding that put us into BLOCKED state, retained until
* a sync actually succeeds (SYNC_COMPLETED with result.success) or
* `clearShrinkBlockedState()` is called. Renderer hydrates the banner
* from this on mount so a block that happened off-screen is still
* visible to the user. */
lastShrinkFinding?: Extract<ShrinkFinding, { suspicious: true }>;
}
export type SyncEventCallback = (event: SyncEvent) => void;
export interface ProviderSyncAnchor {
signature: string | null;
version: number;
updatedAt: number;
deviceId?: string;
resourceId?: string | null;
observedAt: number;
}
interface ProviderAuthRestoreState {
attemptId: number;
connection: ProviderConnection;
adapter: CloudAdapter | null;
}
export type StartProviderAuthResult =
| { type: 'device_code'; data: DeviceFlowState & { authAttemptId: number } }
| { type: 'url'; data: { url: string; redirectUri: string; authAttemptId: number } };
// ============================================================================
// CloudSyncManager Class
// ============================================================================
export class CloudSyncManager {
private state: SyncManagerState;
private stateSnapshot: SyncManagerState; // Immutable snapshot for useSyncExternalStore
private adapters: Map<CloudProvider, CloudAdapter> = new Map();
private eventListeners: Set<SyncEventCallback> = new Set();
private stateChangeListeners: Set<() => void> = new Set(); // For useSyncExternalStore
private autoSyncTimer: ReturnType<typeof setInterval> | null = null;
private masterPassword: string | null = null; // In memory only!
private syncSecurityGeneration = 0;
private hasStorageListener = false;
// Promise that resolves once startup provider secret decryption finishes.
// Awaited by getConnectedAdapter() to prevent using still-encrypted tokens.
private decryptionReady: Promise<void>;
// Per-provider flag: true once that provider's secrets have been
// successfully decrypted. When false, getConnectedAdapter() will
// retry decryption before using the tokens.
private providerDecrypted: Record<CloudProvider, boolean> = {
github: false, google: false, onedrive: false, webdav: false, s3: false,
};
// Per-provider sequence counters for async decrypt callbacks (startup,
// cross-window storage events). Bumped by any state mutation so stale
// decrypt results are discarded.
private providerDecryptSeq: Record<CloudProvider, number> = {
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
};
private providerAuthAttemptSeq: Record<CloudProvider, number> = {
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
};
private providerAuthRestoreState: Record<CloudProvider, ProviderAuthRestoreState | null> = {
github: null, google: null, onedrive: null, webdav: null, s3: null,
};
// Per-provider write sequence counters for saveProviderConnection.
// Only bumped when a new save is initiated, so status-only updates
// (which don't persist) cannot discard an in-flight encrypted write.
private providerWriteSeq: Record<CloudProvider, number> = {
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
};
/** Optional abort signal for the in-flight syncNow / convergent upload path. */
activeSyncAbortSignal: AbortSignal | undefined;
constructor() {
this.state = this.loadInitialState();
this.stateSnapshot = { ...this.state };
this.setupCrossWindowSync();
// Decrypt provider secrets asynchronously after initial load
this.decryptionReady = this.initProviderDecryption();
}
// ==========================================================================
// State Management
// ==========================================================================
private loadInitialState(): SyncManagerState {
return loadInitialStateImpl.call(this);
}
private loadProviderConnection(provider: CloudProvider): ProviderConnection {
return loadProviderConnectionImpl.call(this, provider);
}
/**
* Asynchronously decrypt provider connection secrets after initial load.
* Runs once at construction; decrypted tokens replace the encrypted ones
* in-memory so adapters can use them.
*/
private async initProviderDecryption(): Promise<void> {
return initProviderDecryptionImpl.call(this);
}
private async saveProviderConnection(
provider: CloudProvider,
connection: ProviderConnection,
authAttemptId?: number
): Promise<void> {
return saveProviderConnectionImpl.call(this, provider, connection, authAttemptId);
}
private loadFromStorage<T>(key: string): T | null {
return loadFromStorageImpl.call(this, key);
}
private saveToStorage(key: string, value: unknown): boolean {
return saveToStorageImpl.call(this, key, value);
}
private removeFromStorage(key: string): void {
return removeFromStorageImpl.call(this, key);
}
// ==========================================================================
// Cross-window sync (Electron settings window, etc.)
// ==========================================================================
private setupCrossWindowSync(): void {
return setupCrossWindowSyncImpl.call(this);
}
private safeJsonParse<T>(value: string | null): T | null {
return safeJsonParseImpl.call(this, value);
}
private handleStorageEvent = (event: StorageEvent): void => {
return handleStorageEventImpl.call(this, event);
}
private async getConnectedAdapter(provider: CloudProvider): Promise<CloudAdapter> {
return getConnectedAdapterImpl.call(this, provider);
}
/**
* If `error` indicates OneDrive's refresh token is dead, clear the stale
* tokens so the provider drops to a reconnect state instead of retrying the
* dead token. Returns true when handled. Safe no-op otherwise.
*/
private handleProviderReauthRequired(provider: CloudProvider, error: unknown): boolean {
return handleProviderReauthRequiredImpl.call(this, provider, error);
}
// ==========================================================================
// Event System
// ==========================================================================
subscribe(callback: SyncEventCallback): () => void {
this.eventListeners.add(callback);
return () => this.eventListeners.delete(callback);
}
/**
* Subscribe to state changes for useSyncExternalStore
* This is a simpler subscription that just notifies when state changes
*/
subscribeToStateChanges(callback: () => void): () => void {
this.stateChangeListeners.add(callback);
return () => this.stateChangeListeners.delete(callback);
}
private emit(event: SyncEvent): void {
// Update snapshot and notify state change listeners first
this.notifyStateChange();
// Then notify event listeners
this.eventListeners.forEach(cb => cb(event));
}
/**
* Notify all state change listeners and update snapshot
* Call this after any state mutation
* Uses deep clone to ensure React detects changes in nested objects
*/
private notifyStateChange(): void {
// Deep clone the state to ensure all nested objects are new references
const providers: Record<CloudProvider, ProviderConnection> = {};
for (const [providerId, connection] of Object.entries(this.state.providers)) {
providers[providerId] = { ...connection };
}
this.stateSnapshot = {
...this.state,
providers,
syncHistory: [...this.state.syncHistory],
convergentConflicts: [...this.state.convergentConflicts],
currentConflict: this.state.currentConflict ? { ...this.state.currentConflict } : null,
};
this.stateChangeListeners.forEach(cb => cb());
}
private bumpSyncSecurityGeneration(): void {
this.syncSecurityGeneration += 1;
}
private getSyncSecurityGeneration(): number {
return this.syncSecurityGeneration;
}
private assertSyncSecurityGeneration(expectedGeneration?: number): void {
if (expectedGeneration === undefined) return;
if (
expectedGeneration !== this.syncSecurityGeneration
|| this.state.securityState !== 'UNLOCKED'
|| !this.masterPassword
) {
throw new Error('Sync cancelled because master key changed');
}
}
// ==========================================================================
// Public API - State Accessors
// ==========================================================================
getState(): Readonly<SyncManagerState> {
return this.stateSnapshot;
}
getAdapter(provider: CloudProvider): CloudAdapter | undefined {
return this.adapters.get(provider);
}
getSecurityState(): SecurityState {
return this.state.securityState;
}
getSyncState(): SyncState {
return this.state.syncState;
}
getProviderConnection(provider: CloudProvider): ProviderConnection {
return { ...this.state.providers[provider] };
}
getAllProviders(): Record<CloudProvider, ProviderConnection> {
return { ...this.state.providers };
}
getCurrentConflict(): ConflictInfo | null {
return this.state.currentConflict;
}
isUnlocked(): boolean {
return this.state.securityState === 'UNLOCKED';
}
// ==========================================================================
// Master Key Management
// ==========================================================================
/**
* Set up a new master key (first time setup)
*/
async setupMasterKey(password: string): Promise<void> {
return setupMasterKeyImpl.call(this, password);
}
/**
* Unlock the vault with master password
*/
async unlock(password: string): Promise<boolean> {
return unlockImpl.call(this, password);
}
/**
* Lock the vault
*/
lock(): void {
return lockImpl.call(this);
}
/**
* Change master password
*/
async changeMasterKey(oldPassword: string, newPassword: string): Promise<boolean> {
return changeMasterKeyImpl.call(this, oldPassword, newPassword);
}
/**
* Verify if a password is correct
*/
async verifyPassword(password: string): Promise<boolean> {
return verifyPasswordImpl.call(this, password);
}
// ==========================================================================
// Provider Authentication
// ==========================================================================
/**
* Start authentication flow for a provider
* Returns data needed for the auth flow (device code for GitHub, URL for others).
*
* For PKCE providers (Google / OneDrive) the caller must supply the
* redirect URI the loopback callback server bound to the port is chosen
* dynamically by the main process (#823) so it can't be hardcoded here.
*/
async startProviderAuth(
provider: CloudProvider,
redirectUri?: string
): Promise<StartProviderAuthResult> {
return startProviderAuthImpl.call(this, provider, redirectUri);
}
/**
* Complete GitHub Device Flow authentication
*/
async completeGitHubAuth(
deviceCode: string,
interval: number,
expiresAt: number,
onPending?: () => void,
signal?: AbortSignal,
authAttemptId?: number
): Promise<void> {
return completeGitHubAuthImpl.call(this, deviceCode, interval, expiresAt, onPending, signal, authAttemptId);
}
/**
* Complete PKCE OAuth flow (Google/OneDrive)
*/
async completePKCEAuth(
provider: 'google' | 'onedrive',
code: string,
redirectUri: string,
authAttemptId?: number
): Promise<void> {
return completePKCEAuthImpl.call(this, provider, code, redirectUri, authAttemptId);
}
/**
* Connect config-based providers (WebDAV/S3)
*/
async connectConfigProvider(
provider: 'webdav' | 's3',
config: WebDAVConfig | S3Config
): Promise<void> {
return connectConfigProviderImpl.call(this, provider, config);
}
/**
* Connect a namespaced plugin sync Provider (encrypted-object storage only).
*/
async connectPluginProvider(
providerId: string,
configuration: unknown = {},
credential?: unknown,
): Promise<void> {
return connectPluginProviderImpl.call(this, providerId, configuration, credential);
}
/**
* Replace the live set of contribution-available plugin sync provider IDs.
* Call when plugin contributions change so disabled/uninstalled providers
* leave the auto-sync ready set.
*/
setAvailablePluginSyncProviders(providerIds: readonly string[]): void {
setAvailablePluginSyncProviderIdsImpl.call(this, providerIds);
}
/**
* Reset provider status to disconnected without tearing down existing connections.
* Used when an auth attempt is cancelled/fails avoids destroying a previously
* working connection if the user was re-authenticating.
*/
resetProviderStatus(provider: CloudProvider, authAttemptId?: number): void {
return resetProviderStatusImpl.call(this, provider, authAttemptId);
}
setProviderError(provider: CloudProvider, error: string): void {
return setProviderErrorImpl.call(this, provider, error);
}
/**
* Release the transient 'connecting' UI state without disturbing the adapter
* or the auth restore snapshot. Used by PKCE flows after the browser handoff
* has succeeded, so the settings page isn't visually stuck at "connecting"
* while we wait for the redirect callback in the background.
*/
clearConnectingStatus(provider: CloudProvider): void {
return clearConnectingStatusImpl.call(this, provider);
}
clearProviderError(provider: CloudProvider): void {
return clearProviderErrorImpl.call(this, provider);
}
cancelProviderAuthAttempt(provider: CloudProvider, authAttemptId?: number): void {
return cancelProviderAuthAttemptImpl.call(this, provider, authAttemptId);
}
/**
* Disconnect a provider
*/
async disconnectProvider(provider: CloudProvider): Promise<void> {
return disconnectProviderImpl.call(this, provider);
}
private updateProviderStatus(
provider: CloudProvider,
status: ProviderConnection['status'],
error?: string
): void {
return updateProviderStatusImpl.call(this, provider, status, error);
}
private isActiveAuthAttempt(provider: CloudProvider, authAttemptId: number): boolean {
return isActiveAuthAttemptImpl.call(this, provider, authAttemptId);
}
private buildAccountFromConfig(
provider: 'webdav' | 's3',
config: WebDAVConfig | S3Config
): ProviderAccount {
return buildAccountFromConfigImpl.call(this, provider, config);
}
// ==========================================================================
// Sync Operations
// ==========================================================================
private syncAnchorKey(provider: CloudProvider): string {
return syncAnchorKeyImpl.call(this, provider);
}
private createSyncedFileSignature(syncedFile: SyncedFile | null): Promise<string | null> {
return createSyncedFileSignatureMethodImpl.call(this, syncedFile);
}
private loadSyncAnchor(provider: CloudProvider): ProviderSyncAnchor | null {
return loadSyncAnchorImpl.call(this, provider);
}
private async saveSyncAnchor(
provider: CloudProvider,
syncedFile: SyncedFile | null,
resourceId?: string | null,
): Promise<void> {
return saveSyncAnchorImpl.call(this, provider, syncedFile, resourceId);
}
private clearSyncAnchor(provider?: CloudProvider): void {
return clearSyncAnchorImpl.call(this, provider);
}
private async inspectProviderRemoteState(
provider: CloudProvider,
adapter: CloudAdapter,
): Promise<{
remoteChanged: boolean;
remoteFile: SyncedFile | null;
error?: string;
}> {
return inspectProviderRemoteStateImpl.call(this, provider, adapter);
}
/**
* Helper: Check for conflicts with a specific provider
*
* Fails closed on inspection error: throws rather than returning a
* `{conflict: false, error}` tuple. The previous return-shape let
* `syncAll`'s `validUploads` filter which checks `!r.error` (the
* outer per-provider try/catch error) and `!r.check?.conflict` but
* NOT `r.check?.error` admit this provider into the upload batch
* with `conflict: false`, which then proceeded to upload stale local
* data over the remote (the exact #711/#719 failure mode on a
* transient download 5xx). Throwing surfaces the failure through the
* same per-provider try/catch that already handles connection errors.
*/
private async checkProviderConflict(
provider: CloudProvider,
adapter: CloudAdapter
): Promise<{
conflict: boolean;
remoteFile?: SyncedFile;
}> {
return checkProviderConflictImpl.call(this, provider, adapter);
}
async inspectProviderRemote(provider: CloudProvider): Promise<{
remoteChanged: boolean;
remoteFile: SyncedFile | null;
payload: SyncPayload | null;
}> {
return inspectProviderRemoteImpl.call(this, provider);
}
async commitRemoteInspection(
provider: CloudProvider,
remoteFile: SyncedFile,
payload: SyncPayload,
opts: { recordDownload?: boolean } = {},
): Promise<void> {
return commitRemoteInspectionImpl.call(this, provider, remoteFile, payload, opts);
}
/**
* Helper: Upload encrypted file to a provider
*
* `payloadForBase`, when supplied, is persisted as the new sync base
* BEFORE the anchor is advanced. Ordering matters: if the renderer
* crashes between the two writes, the next startup's inspect must
* either (a) see no anchor advance and re-merge against the fresh
* base, or (b) see both advanced consistently. The previous ordering
* (anchor before base) allowed a crash window where the next run
* saw "remote unchanged" (anchor matched) but silently kept a stale
* base, so a subsequent 3-way merge could misclassify entries that
* landed in this upload.
*/
private async uploadToProvider(
provider: CloudProvider,
adapter: CloudAdapter,
syncedFile: SyncedFile,
payloadForBase?: SyncPayload,
syncSecurityGeneration?: number,
): Promise<SyncResult> {
return uploadToProviderImpl.call(this, provider, adapter, syncedFile, payloadForBase, syncSecurityGeneration);
}
/**
* Build sync payload from current app state
*/
buildPayload(data: {
hosts: SyncPayload['hosts'];
keys: SyncPayload['keys'];
proxyProfiles?: SyncPayload['proxyProfiles'];
snippets: SyncPayload['snippets'];
customGroups: SyncPayload['customGroups'];
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
settings?: SyncPayload['settings'];
}): SyncPayload {
return buildPayloadImpl.call(this, data);
}
/**
* Sync to a specific provider
*/
async syncToProvider(
provider: CloudProvider,
payload: SyncPayload,
opts: {
overrideShrink?: boolean;
applyConvergentPayload?: (
payload: SyncPayload,
commitReplica: () => Promise<void>,
) => Promise<void>;
signal?: AbortSignal;
} = {},
): Promise<SyncResult> {
const previous = this.activeSyncAbortSignal;
this.activeSyncAbortSignal = opts.signal;
try {
return await syncToProviderImpl.call(this, provider, payload, opts);
} finally {
this.activeSyncAbortSignal = previous;
}
}
/**
* Download and apply data from a provider
*/
async downloadFromProvider(provider: CloudProvider): Promise<RemoteSyncPayload | null> {
return downloadFromProviderImpl.call(this, provider);
}
// ========================================================================
// Gist Revision History (#679)
// ========================================================================
/**
* Get the GitHub Gist revision history. Returns an array of
* `{ version (SHA), date }` entries, newest first.
*/
async getGistRevisionHistory(): Promise<Array<{ version: string; date: Date }>> {
return getGistRevisionHistoryImpl.call(this);
}
/**
* Download and decrypt a specific historical Gist revision.
* Returns a structured preview (entity counts) plus the full
* SyncPayload so the caller can offer a one-click restore.
*
* Throws if the revision cannot be decrypted (e.g. encrypted with a
* different master password).
*/
async downloadGistRevision(sha: string): Promise<{
payload: SyncPayload;
meta: import('../../domain/sync').SyncFileMeta;
preview: {
hostCount: number;
keyCount: number;
snippetCount: number;
noteCount: number;
identityCount: number;
portForwardingRuleCount: number;
};
} | null> {
return downloadGistRevisionImpl.call(this, sha);
}
/**
* Resolve a sync conflict
*/
async resolveConflict(resolution: ConflictResolution): Promise<RemoteSyncPayload | null> {
return resolveConflictImpl.call(this, resolution);
}
/**
* Side-effect helper: called BEFORE any syncState assignment that transitions
* away from BLOCKED. Clears lastShrinkFinding and emits SYNC_BLOCKED_CLEARED
* so the UI banner (and any other subscriber) gets a single, authoritative
* "block resolved" signal. The guard on syncState === 'BLOCKED' makes it safe
* to call unconditionally at every non-BLOCKED assignment site it no-ops
* when the state was already non-BLOCKED.
*/
private exitBlockedState(): void {
return exitBlockedStateImpl.call(this);
}
/**
* Reset BLOCKED back to IDLE without going through a successful sync.
* Used by post-merge round-trip to avoid wedging the manager in BLOCKED
* when the merge already produced safe local state and the round-trip
* push is just an optimization.
*/
clearShrinkBlockedState(): void {
return clearShrinkBlockedStateImpl.call(this);
}
/**
* Returns the last shrink finding that triggered BLOCKED state, or
* null if not currently blocked. Used by the renderer to hydrate the
* SyncBlockedBanner when opening Settings after a block happened
* off-screen.
*/
getShrinkBlockedFinding(): Extract<ShrinkFinding, { suspicious: true }> | null {
return getShrinkBlockedFindingImpl.call(this);
}
/**
* Sync to all connected providers
*/
async syncAllProviders(
inputPayload?: SyncPayload,
opts: {
overrideShrink?: boolean;
conflictActionOverride?: CloudSyncConflictAction;
applyConvergentPayload?: (
payload: SyncPayload,
commitReplica: () => Promise<void>,
) => Promise<void>;
signal?: AbortSignal;
} = {},
): Promise<Map<CloudProvider, SyncResult>> {
const previous = this.activeSyncAbortSignal;
this.activeSyncAbortSignal = opts.signal;
try {
return await syncAllProvidersImpl.call(this, inputPayload, opts);
} finally {
this.activeSyncAbortSignal = previous;
}
}
// ==========================================================================
// Auto-Sync
// ==========================================================================
setDeviceName(name: string): void {
return setDeviceNameImpl.call(this, name);
}
setAutoSync(enabled: boolean, intervalMinutes?: number): void {
return setAutoSyncImpl.call(this, enabled, intervalMinutes);
}
setSyncStrategy(strategy: CloudSyncStrategy): void {
this.state.syncStrategy = strategy;
this.saveSyncConfig({ preferencesFromMemory: true, memoryKeys: ['syncStrategy'] });
this.notifyStateChange();
}
setPendingLocalSync(pending: boolean): void {
if (this.state.pendingLocalSync === pending) {
return;
}
this.state.pendingLocalSync = pending;
this.notifyStateChange();
}
private startAutoSync(): void {
return startAutoSyncImpl.call(this);
}
private stopAutoSync(): void {
return stopAutoSyncImpl.call(this);
}
private saveSyncConfig(opts?: {
preferencesFromMemory?: boolean;
memoryKeys?: ReadonlyArray<'autoSync' | 'interval' | 'syncStrategy'>;
}): void {
return saveSyncConfigImpl.call(this, opts);
}
// ==========================================================================
// Sync Base (three-way merge snapshot)
// ==========================================================================
private syncBaseKey(provider?: CloudProvider): string {
return syncBaseKeyImpl.call(this, provider);
}
private syncSnapshotsKey(provider?: CloudProvider): string {
return syncSnapshotsKeyImpl.call(this, provider);
}
private convergentProviderBaselineKey(provider: CloudProvider): string {
return convergentProviderBaselineKeyImpl.call(this, provider);
}
private providerAccountIdKey(provider: CloudProvider): string {
return providerAccountIdKeyImpl.call(this, provider);
}
private loadProviderAccountId(provider: CloudProvider): string | null {
return loadProviderAccountIdImpl.call(this, provider);
}
private saveProviderAccountId(provider: CloudProvider, id: string): void {
return saveProviderAccountIdImpl.call(this, provider, id);
}
async saveSyncBase(payload: SyncPayload, provider?: CloudProvider): Promise<void> {
return saveSyncBaseImpl.call(this, payload, provider);
}
async loadSyncBase(provider?: CloudProvider): Promise<SyncPayload | null> {
return loadSyncBaseImpl.call(this, provider);
}
async loadSyncSnapshots(provider?: CloudProvider): Promise<SyncSnapshotEntry[]> {
return loadSyncSnapshotsImpl.call(this, provider);
}
async saveConvergentReplica(record: ConvergentReplicaRecordV2): Promise<void> {
await saveConvergentReplicaImpl.call(this, record);
this.state.convergentConflicts = materializeConvergentSyncState(record.state).conflicts;
this.notifyStateChange();
}
async loadConvergentReplica(): Promise<ConvergentReplicaRecordV2 | null> {
return loadConvergentReplicaImpl.call(this);
}
async refreshConvergentConflicts(): Promise<void> {
const replica = await this.loadConvergentReplica();
this.state.convergentConflicts = replica
? materializeConvergentSyncState(replica.state).conflicts
: [];
this.notifyStateChange();
}
async saveConvergentProviderBaseline(baseline: ConvergentProviderBaselineV2): Promise<void> {
return saveConvergentProviderBaselineImpl.call(this, baseline);
}
async loadConvergentProviderBaseline(
provider: CloudProvider,
): Promise<ConvergentProviderBaselineV2 | null> {
return loadConvergentProviderBaselineImpl.call(this, provider);
}
async resolveConvergentConflict(
addressKey: string,
candidateDot: string,
applyPayload: (
payload: SyncPayload,
commitReplica: () => Promise<void>,
) => Promise<void>,
options?: {
pluginSidecars?: SyncPayload['pluginSidecars'];
},
): Promise<{ payload: SyncPayload; results: Map<CloudProvider, SyncResult> }> {
return resolveConvergentConflictAndSyncImpl.call(
this,
addressKey,
candidateDot,
applyPayload,
options,
);
}
async previewConvergentRecovery(): Promise<SyncPayload | null> {
return previewConvergentRecoveryImpl.call(this);
}
async downgradeConvergentSync(
confirmed: boolean,
buildLocalPayload: () => SyncPayload | Promise<SyncPayload>,
applyPayload: (
payload: SyncPayload,
commitReplica: () => Promise<void>,
) => Promise<void>,
): Promise<Map<CloudProvider, SyncResult>> {
return downgradeConvergentSyncImpl.call(this, confirmed, buildLocalPayload, applyPayload);
}
async withConvergentSyncLock<T>(task: () => Promise<T>): Promise<T> {
return withConvergentSyncWebLock(task);
}
/** Caller must already hold the convergent Web Lock. */
async syncConvergentProvidersUnderLock(
payload: SyncPayload,
applyPayload: (
payload: SyncPayload,
commitReplica: () => Promise<void>,
) => Promise<void>,
): Promise<Map<CloudProvider, SyncResult>> {
return syncConvergentProvidersUnlockedImpl.call(this, payload, { applyPayload });
}
clearConvergentSyncStorage(confirmed = false): void {
return clearConvergentSyncStorageImpl.call(this, confirmed);
}
private completeConvergentSyncDowngrade(confirmed: boolean): void {
if (!confirmed) throw new Error('Explicit confirmation is required to complete downgrade');
// This method is invoked by downgradeConvergentSyncImpl before it releases
// the cross-window Web Lock. Clear the replica first so a configuration
// persistence failure still cannot let another window re-upload v2 state.
this.clearConvergentSyncStorage(true);
clearConvergentSyncLocalConfigAfterDowngrade(true);
}
private async reencryptSyncStorage(
oldKey: CryptoKey,
newKey: CryptoKey,
newConfig: MasterKeyConfig,
): Promise<void> {
return reencryptSyncStorageImpl.call(this, oldKey, newKey, newConfig);
}
private clearSyncBase(): void {
return clearSyncBaseImpl.call(this);
}
private addSyncHistoryEntry(entry: Omit<SyncHistoryEntry, 'id'>): void {
return addSyncHistoryEntryImpl.call(this, entry);
}
// ==========================================================================
// Local Data Reset
// ==========================================================================
/**
* Resets local version and timestamp to 0.
* This allows the next sync to treat the remote data as newer
* and download it, effectively resetting local vault data.
*/
resetLocalVersion(): void {
return resetLocalVersionImpl.call(this);
}
// ==========================================================================
// Cleanup
// ==========================================================================
destroy(): void {
this.stopAutoSync();
this.lock();
this.eventListeners.clear();
this.adapters.clear();
if (this.hasStorageListener && typeof window !== 'undefined') {
window.removeEventListener('storage', this.handleStorageEvent);
this.hasStorageListener = false;
}
}
}
// Singleton instance
let syncManagerInstance: CloudSyncManager | null = null;
export const getCloudSyncManager = (): CloudSyncManager => {
if (!syncManagerInstance) {
syncManagerInstance = new CloudSyncManager();
}
return syncManagerInstance;
};
export const resetCloudSyncManager = (): void => {
if (syncManagerInstance) {
syncManagerInstance.destroy();
syncManagerInstance = null;
}
};
export default CloudSyncManager;