super-productivity/src/app/imex/sync/sync-wrapper.service.ts
Johannes Millan 542da1eb6c
fix(sync): isolate file-provider state across target changes (#9063)
* fix(sync): invalidate file-provider target state on config change

Task 2 (sync-simplification plan), core increment. The file adapter keys all
per-target state (sync version, revs, vector clocks, seq cursor, within-cycle
caches) by provider id only, and nothing cleared it on a configuration save. A
provider switch, an account switch behind the same provider id, or an
identity-affecting setting change would reuse the previous target's state
against the new target — reading or writing one target's data against another.

- Extract the delete-all reset into a shared _resetTargetState() over a single
  _targetScopedMaps source of truth; this also closes the one-field gap where
  deleteAllData never cleared _lastRecoveredCorruptRev.
- Add invalidateAllTargets() (+ a target generation counter for the later
  in-flight guard) and call it from WrappedProviderService's existing
  providerConfigChanged$ subscription.

Machine-only token refreshes go through the credential store (setComplete), not
setProviderConfig, so they do not fire providerConfigChanged$ and correctly do
not invalidate.

Remaining Task 2 scope (follow-ups): Electron LocalFile picker + Android
setupSaf ingresses that bypass providerConfigChanged$, and in-flight generation
validation before each remote side effect (incl. the #9023 REPAIR rebase loop).

* fix(sync): invalidate file target on LocalFile picker/SAF change

Task 2 follow-up. The Electron LocalFile folder picker (persists main-side
post-#8228) and Android setupSaf() (writes safFolderUri to the credential store)
change the sync target without going through setProviderConfig(), so they never
fired providerConfigChanged$ — leaving the file adapter's per-target revs/clocks/
caches (keyed only by the unchanged LocalFile provider id) pointed at the old
folder. Route both ingresses through the existing providerConfigChanged$ signal
via a module-level bridge, so WrappedProviderService clears its cache and calls
invalidateAllTargets() exactly as it does for a config save.

Remaining Task 2 scope: in-flight generation validation before each remote side
effect (incl. the #9023 REPAIR rebase loop).

* fix(sync): abort file upload when the target changes mid-operation

Task 2 follow-up (in-flight guard). The file adapter keys per-target state by
provider id, and the same provider object reads live config, so a target switch
(provider/account/folder/identity-affecting setting) DURING an upload would let
the in-flight write commit the previous target's merged data to the new target.

Capture the target generation at the _uploadOps boundary and thread a
write-guarded provider (Proxy over uploadFile/removeFile) through every write
path — single-file, split, REPAIR snapshot, and backups. A generation bump
(invalidateAllTargets) between capture and a write throws FileSyncTargetChangedError
before the write; reads pass through. SyncWrapper maps it to UNKNOWN_OR_CHANGED
(silent self-healing re-sync), like a concurrent-upload rev mismatch.

Residual (documented): a check->write TOCTOU window remains (narrowed, not
closed), and the REPAIR rebaseStaleRepair loop across RejectedOpsHandlerService/
RepairOperationService is not yet generation-threaded (follow-up #2b).

Tests: adapter guard (mid-op abort writes nothing; removeFile guarded; reads
pass), sync-wrapper mapping (silent UNKNOWN_OR_CHANGED).

* fix(sync): extend in-flight target guard to snapshot uploads

Completes the Task 2 in-flight guard. #2a guarded _uploadOps, but the file
adapter has a SECOND remote-write entry point — uploadSnapshot/_uploadSnapshot
(initial/recovery/migration + the #9023 REPAIR snapshot via
_conditionalUploadRepairSnapshot and its backups) — which bypassed _uploadOps
and was left unguarded. Apply the same generation-capture-at-boundary +
write-guarded-provider shadow to _uploadSnapshot.

The REPAIR rebase loop itself needs no guard: rebaseStaleRepair (RepairOperation
Service) performs NO remote I/O — it rebuilds the repair op from local state
(stateSnapshotService + opLogStore.replaceRejectedRepair) and defers the
re-upload to the next sync cycle, which flows through the now-guarded write
paths. deleteAllData's removeFile stays unguarded by design: it is a deliberate
user-initiated wipe of a chosen target, not an in-flight-sync write race.

Test: a target switch during the snapshot's archive-load phase aborts before the
write with FileSyncTargetChangedError.

* fix(sync): guard split-migration writes on the download path

Review follow-up (two independent reviewers). The in-flight target guard covered
the two upload entry points, but the split-format DOWNLOAD path also writes: when
a remote carries a pending split-migration marker, _downloadOpsSplit ->
_resumePendingSplitMigration force-writes the state file, tombstone/.bak, and
migration marker via the RAW provider. A target switch during that resume could
land the previous target's migration on the new one — the same corruption class
the guard prevents.

Apply the same generation-capture-at-boundary shadow to _downloadOps so the
resume writes are guarded; reads (downloadFile/getFileRev) still pass through, so
normal downloads are unaffected.

Test (e2c): a target switch during a pending-migration download aborts the resume
writes with FileSyncTargetChangedError.

* docs(sync): correct Task 2 guard comments; make targetGeneration private

Multi-review cleanup (no behavior change):
- Fix the _targetGeneration doc comment (the in-flight guard it called a
  'follow-up' shipped in the same work; it drives _withTargetGuard now).
- Correct the _targetScopedMaps comment, which overstated the guarantee: a
  mid-download switch can repopulate a cache (read path) and a switch between
  download and upload is caught by neither per-operation guard. What actually
  prevents a cross-target write is the generation guard + the conditional-write
  rev check, which self-heals on the next sync — document that honestly.
- Make the targetGeneration getter private (test-only surface; the guard reads
  the private field directly); specs use bracket access.

* fix(sync): abort a download whose target changed before committing its baseline

Multi-review (Codex) found a data-loss window the write-guard missed: a download
READS target A, and if the target then switches mid-download, staging A's
baseline (sync-version/clock/rev) and letting the caller advance the seq cursor
under the shared provider id makes the NEXT sync skip the new target's ops from a
stale cursor. The write-guard only covers writes; reads pass through.

Capture the generation at the download boundary and, before committing the
baseline (single-file and split paths), abort + reset the target-scoped state if
it changed — SyncWrapper maps the error to a silent self-heal. Closes the
dominant switch-during-download window; a switch after a successful download
(during op-apply / an upload's own cursor commit) is a narrower residual only
per-cycle session capture can fully close (documented).

* fix(sync): map FileSyncTargetChangedError on the force-upload paths

Multi-review (Codex): the error was only mapped in the normal sync() catch. The
forceUpload and USE_LOCAL conflict-resolution catches handled only
EncryptNoPasswordError, so a target switch during a force upload aborted safely
(guard held — no data loss) but surfaced a scary ERROR snack instead of the
silent UNKNOWN_OR_CHANGED self-heal. Add the branch to both force paths.

* refactor(sync): compile-enforce the in-flight guard via a branded provider type

Multi-review (Architecture) hardening: the guard was threaded by an implicit
call-graph convention — a future entry point or write-helper caller that forgot
the _withTargetGuard shadow would silently re-open cross-target writes, the
worst failure class here. Introduce a phantom-branded GuardedFileSyncProvider
(FileSyncProvider & { [unique symbol]: true }) returned by _withTargetGuard and
required by every write-path helper param. Passing a raw provider to a write
path is now a compile error (verified: TS2345). Only createAdapter and the
intentionally-unguarded _deleteAllData keep the raw type. No runtime/behavior
change (brand is a phantom type); adapter suite 135/135.

* fix(sync): invalidate file-adapter state only on real target moves

Task 2's invalidation was wired to "any privateCfg was saved", but its
semantics are "the sync target moved". setProviderConfig() fires
providerConfigChanged$ unconditionally, so two content-only writes
reached invalidateAllTargets():

1. The sync-settings dialog saves with isForce=true, which bypasses the
   JSON-equality dedup, and _updatePrivateConfig then rewrites privateCfg
   unconditionally. So changing the sync interval, toggling compression,
   or pressing Save with nothing changed wiped _localSeqCounters and
   persisted it. A cursor back at 0 makes the next download return a
   snapshotState (isForceFromZero); for a client holding unsynced ops
   that classifies CONCURRENT, and with AUTO_MERGE_CONCURRENT_SNAPSHOT
   false it dead-ends in a binary conflict dialog whose either answer
   discards data. file-based-sync-adapter.service.ts already documented
   this exact hazard at the latestSeq computation.

2. WrappedProviderService fires the GHSA-9544 isEncryptionEnabled
   backfill fire-and-forget on adapter creation. Its setProviderConfig()
   lands within ms (local IO) while the sync it was spawned from is still
   on the network, so it wiped the cursor mid-cycle and aborted that sync
   with FileSyncTargetChangedError even though nothing moved. (The abort
   does not heal the cursor: invalidateAllTargets clears and persists it
   before the abort throws, so the next cycle still bootstraps from 0.)

providerConfigChanged$ now carries isTargetChanged, set from
isSyncTargetChanged(). Every subscriber still drops config-derived caches
(the adapter closes over the resolved encryption key/intent); only a real
move additionally calls invalidateAllTargets(). Both facts ride one
emission so a caller cannot raise a move without the cache drop.

isSyncTargetChanged treats only encryptKey/isEncryptionEnabled as
content-only and everything else as identity-affecting, so an unknown or
newly added field errs toward invalidating rather than reusing one
target's cursor against another. Both directions can lose data; the false
negative is worse only because it is silent.

The OneDrive pre-auth cfg write (dialog-sync-cfg) bypasses
setProviderConfig, so by the time the save's setProviderConfig runs the
diff is a no-op and a folder move would have silently kept the previous
folder's cursor. It now asserts the move directly, gated on a real diff
since it runs on every save. Covered by a test verified to fail without
the fix.

Also correct two comments: invalidateAllTargets' trigger, and the
download-abort's mechanism (a stale cursor suppresses gap detection so
the new target's snapshot is never loaded — it does not skip ops; both
download paths return every op and dedup via appliedOpIds).

Known residual: OneDrive's PROVIDER_FIELD_DEFAULTS are the only ones not
seeded with '', so a config missing one still reports a spurious move on
its first save (one-time, self-correcting). Documented at isUnset.
2026-07-16 15:21:16 +02:00

1735 lines
73 KiB
TypeScript

import { DestroyRef, inject, Injectable } from '@angular/core';
import { BehaviorSubject, combineLatest, firstValueFrom, Observable, of } from 'rxjs';
import { GlobalConfigService } from '../../features/config/global-config.service';
import {
distinctUntilChanged,
filter,
first,
map,
shareReplay,
switchMap,
timeout,
} from 'rxjs/operators';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import {
SyncAlreadyInProgressError,
LockAcquisitionTimeoutError,
LocalDataConflictError,
WebCryptoNotAvailableError,
MissingRefreshTokenAPIError,
HttpNotOkAPIError,
EmptyRemoteBodySPError,
JsonParseError,
LegacySyncFormatDetectedError,
IncompleteRemoteOperationsError,
SyncDataCorruptedError,
UploadRevToMatchMismatchAPIError,
ForceUploadFailedError,
ForceUploadPendingOpsError,
FileSyncTargetChangedError,
} from '../../op-log/core/errors/sync-errors';
import { MAX_LWW_REUPLOAD_RETRIES } from '../../op-log/core/operation-log.const';
import { SyncConfig } from '../../features/config/global-config.model';
import { TranslateService } from '@ngx-translate/core';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { SnackService } from '../../core/snack/snack.service';
import {
AuthFailSPError,
CanNotMigrateMajorDownError,
ConflictData,
ConflictReason,
DecryptError,
DecryptNoPasswordError,
OperationIntegrityError,
EncryptNoPasswordError,
MissingCredentialsSPError,
NetworkUnavailableSPError,
PotentialCorsError,
SyncProviderId,
SyncStatus,
toSyncProviderId,
} from '../../op-log/sync-exports';
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service';
import { T } from '../../t.const';
import { getSyncErrorStr } from './get-sync-error-str';
import { getErrorTxt } from '../../util/get-error-text';
import { escapeHtml } from '../../util/escape-html';
import { DialogGetAndEnterAuthCodeComponent } from './dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component';
import { DialogConflictResolutionResult } from './sync.model';
import { DialogSyncConflictComponent } from './dialog-sync-conflict/dialog-sync-conflict.component';
import { ReminderService } from '../../features/reminder/reminder.service';
import { DialogHandleDecryptErrorComponent } from './dialog-handle-decrypt-error/dialog-handle-decrypt-error.component';
import { DialogEnterEncryptionPasswordComponent } from './dialog-enter-encryption-password/dialog-enter-encryption-password.component';
import { SyncLog } from '../../core/log';
import { promiseTimeout } from '../../util/promise-timeout';
import { devError } from '../../util/dev-error';
import { alertDialog, confirmDialog } from '../../util/native-dialogs';
import { UserInputWaitStateService } from './user-input-wait-state.service';
import { SYNC_WAIT_TIMEOUT_MS } from './sync.const';
import { SuperSyncStatusService } from '../../op-log/sync/super-sync-status.service';
import { SuperSyncWebSocketService } from '../../op-log/sync/super-sync-websocket.service';
import { WsTriggeredDownloadService } from '../../op-log/sync/ws-triggered-download.service';
import { IS_ELECTRON } from '../../app.constants';
import { OperationLogStoreService } from '../../op-log/persistence/operation-log-store.service';
import { OperationLogSyncService } from '../../op-log/sync/operation-log-sync.service';
import { SyncSessionValidationService } from '../../op-log/sync/sync-session-validation.service';
import { SyncCycleGuardService } from '../../op-log/sync/sync-cycle-guard.service';
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
import { isSuperSyncWebSocketAccess } from '@sp/sync-providers/super-sync';
import { isTransientNetworkError } from '@sp/sync-providers/http';
import { HydrationStateService } from '../../op-log/apply/hydration-state.service';
import type { UploadOutcome } from '../../op-log/core/types/sync-results.types';
type CompletedUploadOutcome = Extract<UploadOutcome, { kind: 'completed' }>;
/**
* Identifies which error or UI path triggered a destructive forceUpload.
* Logged on every invocation so a future sync-stuck incident can be traced
* back to its origin without diff-archaeology.
*/
export type ForceUploadTriggerSource =
| 'EmptyRemoteBodySPError'
| 'JsonParseError'
| 'LegacySyncFormatDetectedError'
| 'DecryptError'
| 'unknown';
/**
* When the post-sync SuperSync encryption prompt fires while another dialog is
* still open (typically the sync-config dialog still playing its close animation
* right after first-time setup), defer rather than drop the prompt: poll until
* dialogs clear, up to this bound, then re-check state.
* See `_promptSuperSyncEncryptionIfNeeded` (#8670 regression).
*/
const ENCRYPTION_PROMPT_DIALOG_WAIT_MS = 8000;
const ENCRYPTION_PROMPT_DIALOG_POLL_MS = 100;
@Injectable({
providedIn: 'root',
})
export class SyncWrapperService {
private _providerManager = inject(SyncProviderManager);
private _legacyPfDb = inject(LegacyPfDbService);
private _globalConfigService = inject(GlobalConfigService);
private _translateService = inject(TranslateService);
private _snackService = inject(SnackService);
private _matDialog = inject(MatDialog);
private _reminderService = inject(ReminderService);
private _userInputWaitState = inject(UserInputWaitStateService);
private _superSyncStatusService = inject(SuperSyncStatusService);
private _superSyncWsService = inject(SuperSyncWebSocketService);
private _wsDownloadService = inject(WsTriggeredDownloadService);
private _opLogStore = inject(OperationLogStoreService);
private _opLogSyncService = inject(OperationLogSyncService);
private _sessionValidation = inject(SyncSessionValidationService);
private _syncCycleGuard = inject(SyncCycleGuardService);
private _wrappedProvider = inject(WrappedProviderService);
private _hydrationState = inject(HydrationStateService);
syncState$ = this._providerManager.syncStatus$;
syncCfg$: Observable<SyncConfig> = this._globalConfigService.cfg$.pipe(
map((cfg) => cfg?.sync),
);
syncProviderId$: Observable<SyncProviderId | null> = this.syncCfg$.pipe(
map((cfg) => toSyncProviderId(cfg.syncProvider)),
);
private _destroyRef = inject(DestroyRef);
// Disconnect WebSocket when sync provider changes away from SuperSync or sync is disabled
private _wsProviderCleanup = this.syncProviderId$
.pipe(distinctUntilChanged(), takeUntilDestroyed(this._destroyRef))
.subscribe((providerId) => {
if (providerId !== SyncProviderId.SuperSync) {
this.disconnectWebSocket();
}
});
/**
* Sync interval in milliseconds.
* - When WebSocket is connected: 5 minutes (health check only)
* - When WebSocket is disconnected: 1 minute for SuperSync
* - Other providers: user-configured value
* - Return 0 when manual sync only is enabled to disable automatic triggers
*/
syncInterval$: Observable<number> = combineLatest([
this.syncCfg$,
toObservable(this._superSyncWsService.isConnected),
]).pipe(
map(([cfg, wsConnected]) => {
if (cfg.isManualSyncOnly) return 0;
if (cfg.syncProvider === SyncProviderId.SuperSync) {
return wsConnected ? 300_000 : 60_000;
}
return cfg.syncInterval;
}),
distinctUntilChanged(),
);
isEnabledAndReady$: Observable<boolean> = this._providerManager.isProviderReady$;
// NOTE we don't use this._pfapiService.isSyncInProgress$ since it does not include handling and re-init view model
private _isSyncInProgress$ = new BehaviorSubject(false);
isSyncInProgress$ = this._isSyncInProgress$.asObservable();
/**
* Flag to block sync during critical encryption operations (password change, enable/disable).
* When true, sync() will skip and return immediately. This prevents race conditions where
* sync could read partial encryption state during password change.
*/
private _isEncryptionOperationInProgress$ = new BehaviorSubject(false);
/**
* When true, encryption-related dialogs (missing password, decrypt error) are suppressed.
* Set after the user cancels a dialog so they can navigate to settings to change the password
* without being blocked by recurring auto-sync dialogs. Cleared when encryption config changes.
*/
private _suppressEncryptionDialogs = false;
/**
* Tracks consecutive SuperSync AuthFailSPError occurrences.
* Tolerates up to 2 transient 401s (e.g. infrastructure errors);
* on the 3rd consecutive failure, clears the token so re-auth dialog opens.
* Reset to 0 on any successful sync or any non-auth error.
*/
private _consecutiveSuperSyncAuthFailures = 0;
/**
* Observable for UI: true when all local changes have been uploaded.
* Used for all sync providers to show the single checkmark indicator.
*/
hasNoPendingOps$: Observable<boolean> = toObservable(
this._superSyncStatusService.hasNoPendingOps,
).pipe(distinctUntilChanged(), shareReplay(1));
/**
* Observable for UI: true when sync is confirmed fully in sync
* (no pending ops AND remote recently checked within 1 minute).
* Used for all sync providers to show the double checkmark indicator.
*/
superSyncIsConfirmedInSync$: Observable<boolean> = toObservable(
this._superSyncStatusService.isConfirmedInSync,
).pipe(distinctUntilChanged(), shareReplay(1));
isSyncInProgressSync(): boolean {
return this._isSyncInProgress$.getValue();
}
/**
* Returns true if an encryption operation (password change, enable/disable) is in progress.
* Used by ImmediateUploadService to avoid uploading during critical encryption operations.
*/
get isEncryptionOperationInProgress(): boolean {
return this._isEncryptionOperationInProgress$.getValue();
}
/**
* Clears the suppression flag so encryption dialogs can appear again.
* Called after the user changes the encryption password via settings.
*/
clearEncryptionDialogSuppression(): void {
this._suppressEncryptionDialogs = false;
}
// Expose shared user input wait state for other services (e.g., SyncTriggerService)
isWaitingForUserInput$ = this._userInputWaitState.isWaitingForUserInput$;
afterCurrentSyncDoneOrSyncDisabled$: Observable<unknown> = this.isEnabledAndReady$.pipe(
switchMap((isEnabled) =>
isEnabled
? this._isSyncInProgress$.pipe(
filter((isInProgress) => !isInProgress),
timeout({
each: SYNC_WAIT_TIMEOUT_MS,
with: () =>
// If waiting for user input, don't error - just wait indefinitely
this._userInputWaitState.isWaitingForUserInput$.pipe(
switchMap((isWaiting) => {
if (isWaiting) {
// Continue waiting for sync to complete (no timeout)
return this._isSyncInProgress$.pipe(
filter((isInProgress) => !isInProgress),
);
}
devError('Sync wait timeout exceeded');
return of(undefined);
}),
),
}),
)
: of(undefined),
),
first(),
);
/**
* @param isUserTriggered `true` when the sync was explicitly requested by the
* user (sync button, saving sync config). Automatic syncs (app resume/focus,
* interval, internal retries) pass `false` so that transient network failures
* — common right after Android wakes from Doze, before sockets/DNS recover —
* stay silent instead of flashing a self-healing "temporary network problem"
* snackbar the user never asked about. The next sync cycle retries anyway.
*/
async sync(isUserTriggered = false): Promise<SyncStatus | 'HANDLED_ERROR'> {
// Block sync if encryption operation is in progress (password change, enable/disable)
if (this._isEncryptionOperationInProgress$.getValue()) {
SyncLog.log('Sync blocked: encryption operation in progress');
return 'HANDLED_ERROR';
}
// Clear suppression so encryption dialogs can appear again on each sync attempt
this._suppressEncryptionDialogs = false;
// Race condition fix: Check-and-set atomically before starting sync
if (this._isSyncInProgress$.getValue()) {
SyncLog.log('Sync already in progress, skipping concurrent sync attempt');
return 'HANDLED_ERROR';
}
this._isSyncInProgress$.next(true);
// #8309: claim the in-tab sync cycle so the conflict-gate decision,
// setLastServerSeq, and the session-validation latch are serialized against
// the side channels (immediate upload / WS download). Both this claim and
// the re-entry check above are synchronous (no await between), so the
// check-and-set stays atomic. If a (short-lived) side channel is mid-cycle
// we skip rather than block — the next trigger retries. Released in the
// finally below.
if (!this._syncCycleGuard.tryBegin()) {
this._isSyncInProgress$.next(false);
SyncLog.log('Sync skipped: another sync cycle is in progress');
return 'HANDLED_ERROR';
}
// Open before any async work — see `HydrationStateService.isInSyncWindow`.
// Pass 0 to disable the failsafe: the `finally` block below is the
// authoritative close, and a slow sync (provider I/O > 2s) would
// otherwise expire the timer mid-sync and leave a stale-state gap.
this._hydrationState.openSyncWindow(0);
// Set SYNCING status so ImmediateUploadService knows not to interfere
this._providerManager.setSyncStatus('SYNCING');
const result = await this._sync(isUserTriggered).finally(() => {
this._isSyncInProgress$.next(false);
this._hydrationState.closeSyncWindow();
this._syncCycleGuard.end();
// Safeguard: if _sync() threw or completed without setting a final status,
// reset from SYNCING to UNKNOWN_OR_CHANGED to avoid getting stuck in SYNCING state
if (this._providerManager.isSyncInProgress) {
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
}
});
// After any successful sync, prompt for encryption if SuperSync is active
// without it. This ensures data is downloaded and merged first, preventing
// data loss. Note: a successful sync now returns UpdateRemote when data
// changed and InSync only when nothing changed (discussion #7196), so this
// gate must accept both — only HANDLED_ERROR skips the prompt.
if (result !== 'HANDLED_ERROR') {
this._promptSuperSyncEncryptionIfNeeded().catch((err) => {
SyncLog.err('Error prompting for encryption:', err);
});
}
return result;
}
/**
* Runs an encryption operation (password change, enable, disable) with sync blocked.
*
* This method:
* 1. Waits for any ongoing sync to complete
* 2. Blocks new syncs from starting
* 3. Runs the operation
* 4. Unblocks sync
*
* This prevents race conditions where sync could interfere with encryption state changes.
*
* @param operation - The async operation to run with sync blocked
* @returns The result of the operation
*/
async runWithSyncBlocked<T>(operation: () => Promise<T>): Promise<T> {
// Wait for any ongoing sync to complete (with timeout)
if (this._isSyncInProgress$.getValue()) {
SyncLog.log('Waiting for ongoing sync to complete before encryption operation...');
try {
// Race between sync completing and timeout
await Promise.race([
firstValueFrom(
this._isSyncInProgress$.pipe(filter((inProgress) => !inProgress)),
),
promiseTimeout(SYNC_WAIT_TIMEOUT_MS).then(() => {
throw new Error('Timeout waiting for sync');
}),
]);
} catch (e) {
throw new Error(
'Cannot change encryption settings: sync timed out. Please try again.',
);
}
}
// Block new syncs
this._isEncryptionOperationInProgress$.next(true);
SyncLog.log('Sync blocked for encryption operation');
try {
return await operation();
} finally {
// Unblock sync
this._isEncryptionOperationInProgress$.next(false);
SyncLog.log('Sync unblocked after encryption operation');
}
}
/**
* Connects the WebSocket for real-time sync notifications.
* Called after a successful SuperSync sync cycle.
*/
async connectWebSocket(): Promise<void> {
const providerId = await firstValueFrom(this.syncProviderId$);
if (providerId !== SyncProviderId.SuperSync) {
return;
}
const provider = await this._providerManager.getProviderById(
SyncProviderId.SuperSync,
);
if (!provider) {
SyncLog.warn(
'SyncWrapperService: No SuperSync provider found for WebSocket connection',
);
return;
}
if (!isSuperSyncWebSocketAccess(provider)) {
SyncLog.warn(
'SyncWrapperService: SuperSync provider does not expose WebSocket access',
);
return;
}
const wsParams = await provider.getWebSocketParams();
if (!wsParams) {
SyncLog.warn(
'SyncWrapperService: No WebSocket params available from SuperSync provider',
);
return;
}
await this._superSyncWsService.connect(wsParams.baseUrl, wsParams.accessToken);
this._wsDownloadService.start();
}
/**
* Disconnects the WebSocket and stops WS-triggered downloads.
*/
disconnectWebSocket(): void {
this._wsDownloadService.stop();
this._superSyncWsService.disconnect();
}
private async _sync(isUserTriggered: boolean): Promise<SyncStatus | 'HANDLED_ERROR'> {
const providerId = await firstValueFrom(this.syncProviderId$);
if (!providerId) {
throw new Error('No Sync Provider for sync()');
}
// Open a session-validation scope for this sync. Any post-sync
// validation failure during the session (download, upload, piggyback,
// retry, USE_REMOTE force-download) flips the latch; the wrapper reads
// it once before claiming IN_SYNC. (#7330)
return this._sessionValidation.withSession(() =>
this._syncBody(providerId, isUserTriggered),
);
}
private async _syncBody(
providerId: SyncProviderId,
isUserTriggered: boolean,
): Promise<SyncStatus | 'HANDLED_ERROR'> {
try {
// PERF: For legacy sync providers (WebDAV, Dropbox, LocalFile), sync the vector clock
// from SUP_OPS to pf.META_MODEL before sync. This bridges the gap between the new
// atomic write system (vector clock in SUP_OPS) and legacy sync which reads from pf.
// SuperSync uses operation log directly, so it doesn't need this bridge.
if (providerId !== SyncProviderId.SuperSync) {
await this._syncVectorClockToPfapi();
}
// Get the sync-capable version of the provider
// - SuperSync: returned as-is (already implements OperationSyncCapable)
// - File-based (Dropbox, WebDAV, LocalFile): wrapped with FileBasedSyncAdapterService
const rawProvider = this._providerManager.getActiveProvider();
const syncCapableProvider =
await this._wrappedProvider.getOperationSyncCapable(rawProvider);
if (!syncCapableProvider) {
SyncLog.warn('SyncWrapperService: Provider does not support operation sync');
return SyncStatus.InSync;
}
// Perform actual sync: download first, then upload
SyncLog.log('SyncWrapperService: Starting op-log sync...');
// Detect provider switch: force fresh download to trigger conflict dialog
const lastSyncedProvider = this._providerManager.getLastSyncedProviderId();
const isProviderSwitch =
lastSyncedProvider !== null && lastSyncedProvider !== providerId;
if (isProviderSwitch) {
SyncLog.warn(
`SyncWrapperService: Provider switch detected (${lastSyncedProvider}${providerId}). ` +
'Forcing download from seq 0 for fresh state comparison.',
);
}
// Capture sync history BEFORE download. The SYNC_IMPORT conflict gate (both the
// download and piggyback-upload paths) uses this to decide whether a USE_LOCAL
// choice would overwrite a populated remote with throwaway pre-first-sync state.
// It MUST be read here, before this sync persists any synced ops, or the
// never-synced guard reads its own just-written state and disarms itself.
const isNeverSyncedAtSyncStart = !(await this._opLogSyncService.hasSyncedOps());
// 1. Download remote ops first (important for fresh clients to receive data)
const downloadResult = await this._opLogSyncService.downloadRemoteOps(
syncCapableProvider,
{
forceFromSeq0: isProviderSwitch || undefined,
isNeverSynced: isNeverSyncedAtSyncStart,
},
);
// Auth is confirmed working if download didn't throw AuthFailSPError.
// Reset here rather than only at InSync so early returns (cancelled,
// LWW pending, payload rejected) also break the consecutive-failure chain.
this._consecutiveSuperSyncAuthFailures = 0;
SyncLog.log(`SyncWrapperService: Download complete. kind=${downloadResult.kind}`);
// If user cancelled the sync import conflict dialog, skip upload entirely.
// This keeps the local state unchanged and doesn't push it to the server.
// Don't update lastSyncedProvider so the next sync retries with forceFromSeq0.
if (downloadResult.kind === 'cancelled') {
SyncLog.log('SyncWrapperService: Sync cancelled by user. Skipping upload phase.');
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return 'HANDLED_ERROR';
}
if (downloadResult.kind === 'blocked_incompatible') {
SyncLog.warn(
'SyncWrapperService: Download blocked by an incompatible operation.',
);
this._providerManager.setSyncStatus('ERROR');
return 'HANDLED_ERROR';
}
// Track the successfully synced provider for switch detection on next sync
this._providerManager.setLastSyncedProviderId(providerId);
// 2. Upload pending local ops
const uploadResult = await this._opLogSyncService.uploadPendingOps(
syncCapableProvider,
{ isNeverSynced: isNeverSyncedAtSyncStart },
);
if (uploadResult.kind === 'blocked_incompatible') {
SyncLog.warn(
'SyncWrapperService: Upload piggyback blocked by an incompatible operation.',
);
this._providerManager.setSyncStatus('ERROR');
return 'HANDLED_ERROR';
}
const completedUploadResults: CompletedUploadOutcome[] =
uploadResult.kind === 'completed' ? [uploadResult] : [];
if (uploadResult.kind === 'completed') {
SyncLog.log(
`SyncWrapperService: Upload complete. uploaded=${uploadResult.uploadedCount}, piggybacked=${uploadResult.piggybackedOpsCount}`,
);
}
// If upload was cancelled (piggybacked SYNC_IMPORT conflict dialog), skip LWW re-upload
if (uploadResult.kind === 'cancelled') {
SyncLog.log(
'SyncWrapperService: Upload cancelled by user (piggybacked SYNC_IMPORT). Skipping LWW re-upload.',
);
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return 'HANDLED_ERROR';
}
// If the provider mandates encryption but no key is configured yet, the upload was
// skipped with pending ops still unsynced (GHSA-9v8x guard). Downloads still ran
// (merge-first), but nothing local can be uploaded until encryption is set up — so
// this is NOT in sync. Report it honestly instead of falling through to IN_SYNC.
// Also short-circuit the LWW loop below: its local-win ops are blocked by the same
// missing key and would just spin to the retry cap.
if (
uploadResult.kind === 'completed' &&
uploadResult.encryptionRequiredKeyMissing
) {
SyncLog.log(
'SyncWrapperService: Upload skipped — encryption required but no key configured. ' +
'Reporting UNKNOWN_OR_CHANGED (sync paused until encryption is set up).',
);
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return SyncStatus.UpdateRemote;
}
// 3. If LWW created local-win ops, upload them (with retry limit to prevent infinite loops)
const downloadLwwOps =
downloadResult.kind === 'ops_processed' ? downloadResult.localWinOpsCreated : 0;
const uploadLwwOps =
uploadResult.kind === 'completed' ? uploadResult.localWinOpsCreated : 0;
let lwwRetries = 0;
let pendingLwwOps = downloadLwwOps + uploadLwwOps;
while (pendingLwwOps > 0 && lwwRetries < MAX_LWW_REUPLOAD_RETRIES) {
lwwRetries++;
SyncLog.log(
`SyncWrapperService: Re-uploading ${pendingLwwOps} local-win op(s) from LWW ` +
`(attempt ${lwwRetries}/${MAX_LWW_REUPLOAD_RETRIES})...`,
);
// Re-thread isNeverSyncedAtSyncStart (the snapshot captured BEFORE the
// initial upload ran) instead of letting uploadPendingOps re-read live
// state — the initial batch has already flipped hasSyncedOps() to true
// and a live read here would mis-classify a still-fresh client. Mirrors
// the orchestrator-snapshot rationale at the top of uploadPendingOps.
const reuploadResult = await this._opLogSyncService.uploadPendingOps(
syncCapableProvider,
{ isNeverSynced: isNeverSyncedAtSyncStart },
);
if (reuploadResult.kind === 'cancelled') {
// Mirror the initial-upload cancel path: a cancelled LWW re-upload
// means downloaded localWinOpsCreated stay pending in the op-log.
// UNKNOWN_OR_CHANGED forces a retry on the next sync tick.
SyncLog.log(
'SyncWrapperService: LWW re-upload cancelled by user. Skipping remaining sync work.',
);
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return 'HANDLED_ERROR';
}
if (reuploadResult.kind === 'blocked_incompatible') {
SyncLog.warn(
'SyncWrapperService: LWW re-upload blocked by an incompatible operation.',
);
this._providerManager.setSyncStatus('ERROR');
return 'HANDLED_ERROR';
}
if (reuploadResult.kind === 'completed') {
completedUploadResults.push(reuploadResult);
}
pendingLwwOps =
reuploadResult.kind === 'completed' ? reuploadResult.localWinOpsCreated : 0;
}
if (completedUploadResults.some((result) => result.blockedByRejectedFullState)) {
SyncLog.err(
'SyncWrapperService: Upload blocked because pending operations depend on a rejected full-state baseline.',
);
this._providerManager.setSyncStatus('ERROR');
return 'HANDLED_ERROR';
}
if (pendingLwwOps > 0) {
SyncLog.warn(
`SyncWrapperService: LWW re-upload still has ${pendingLwwOps} pending ops after ` +
`${MAX_LWW_REUPLOAD_RETRIES} retries. Will retry on next sync.`,
);
// Issue #7521: validation failure is more serious than unuploaded
// ops — prefer ERROR over UNKNOWN_OR_CHANGED if the latch was
// flipped at any point during the session.
if (this._sessionValidation.hasFailed()) {
SyncLog.err(
'SyncWrapperService: Validation failed during sync (retry exhaustion path); reporting ERROR',
);
this._providerManager.setSyncStatus('ERROR');
return 'HANDLED_ERROR';
}
if (this._handlePermanentUploadRejections(completedUploadResults)) {
return 'HANDLED_ERROR';
}
// Don't claim IN_SYNC — there are known unuploaded ops.
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return SyncStatus.UpdateRemote;
}
// Issue #7330: post-sync state validation failure must not be reported
// as IN_SYNC. The latch is flipped by every validation site; we read it
// once here before claiming IN_SYNC.
if (this._sessionValidation.hasFailed()) {
SyncLog.err(
'SyncWrapperService: Post-sync state validation failed, not marking as IN_SYNC',
);
this._providerManager.setSyncStatus('ERROR');
return 'HANDLED_ERROR';
}
// 4. Check for permanent rejection failures
if (this._handlePermanentUploadRejections(completedUploadResults)) {
return 'HANDLED_ERROR';
}
// Mark as in-sync for all providers after successful sync
this._providerManager.setSyncStatus('IN_SYNC');
SyncLog.log('SyncWrapperService: Sync complete, status=IN_SYNC');
// Did this sync actually move any data (either direction)? Used by the
// sync button to show "Data successfully synced" vs "Already in sync"
// (discussion #7196). UpdateRemote is reused here as the generic
// "data changed this sync" status — the only consumer that inspects the
// return value (main-header) treats all UpdateLocal/UpdateRemote variants
// identically, so no new enum value is needed.
const didDownloadChanges =
downloadResult.kind === 'snapshot_hydrated' ||
downloadResult.kind === 'server_migration_handled' ||
(downloadResult.kind === 'ops_processed' && downloadResult.newOpsCount > 0);
const didUploadChanges =
uploadResult.kind === 'completed' && uploadResult.uploadedCount > 0;
const didChange = didDownloadChanges || didUploadChanges;
// Connect WebSocket after first successful SuperSync sync (fire-and-forget)
if (
providerId === SyncProviderId.SuperSync &&
!this._superSyncWsService.isConnected()
) {
this.connectWebSocket().catch((err) => {
SyncLog.warn(
'SyncWrapperService: WebSocket connection failed, will retry on next sync',
err,
);
});
}
return didChange ? SyncStatus.UpdateRemote : SyncStatus.InSync;
} catch (error) {
// DecryptNoPasswordError is expected control flow, not a failure: it signals the
// app to prompt for the encryption password (handled below). The download/upload
// service already logged it at the right severity — quiet for a fresh-client
// onboarding prompt, loud for the dropped-credential signature. Re-logging it here
// at error level would re-raise the very noise that scoping was meant to remove.
if (!(error instanceof DecryptNoPasswordError)) {
SyncLog.err(error);
}
// Reset consecutive SuperSync auth failure counter for non-auth errors.
// Only AuthFailSPError for SuperSync should accumulate the counter.
if (
!(error instanceof AuthFailSPError) ||
providerId !== SyncProviderId.SuperSync
) {
this._consecutiveSuperSyncAuthFailures = 0;
}
if (error instanceof PotentialCorsError) {
this._snackService.open({
msg: T.F.SYNC.S.ERROR_CORS,
type: 'ERROR',
// a bit longer since it is a long message
config: { duration: 12000 },
});
return 'HANDLED_ERROR';
} else if (error instanceof IncompleteRemoteOperationsError) {
this._providerManager.setSyncStatus('ERROR');
if (!this._snackService.hasPendingPersistentAction()) {
this._snackService.open({
msg: T.F.SYNC.S.INCOMPLETE_REMOTE_OPERATIONS,
type: 'ERROR',
config: { duration: 0 },
});
}
return 'HANDLED_ERROR';
} else if (
error instanceof AuthFailSPError ||
error instanceof MissingRefreshTokenAPIError ||
error instanceof MissingCredentialsSPError
) {
this._providerManager.setSyncStatus('ERROR');
this._superSyncStatusService.clearScope();
// Clear stale auth credentials so isReady() returns false and re-auth dialog opens.
// SuperSync AuthFailSPError gets special handling: tolerate up to 2 transient 401s
// (e.g. infrastructure errors returning 401 instead of 500), but on the 3rd
// consecutive failure, clear the token to break the stale-credential loop.
// Dropbox clears its refreshable OAuth token immediately so its re-auth flow
// works. WebDAV/Nextcloud intentionally do NOT clear: the credential is a
// user-typed (often irrecoverable) password, not a refreshable token — they
// expose no clearAuthCredentials hook, so clearAuthCredentials() below is a
// no-op for them and the actionable snackbar handles recovery without
// destroying the user's config. See issue #7616 — do NOT re-add a WebDAV
// clearAuthCredentials override.
let skipClear = false;
if (error instanceof AuthFailSPError && providerId === SyncProviderId.SuperSync) {
this._consecutiveSuperSyncAuthFailures++;
if (this._consecutiveSuperSyncAuthFailures < 3) {
skipClear = true;
} else {
this._consecutiveSuperSyncAuthFailures = 0;
}
}
if (providerId && !skipClear) {
try {
await this._providerManager.clearAuthCredentials(providerId);
} catch (clearError) {
SyncLog.err('Failed to clear stale auth credentials:', clearError);
}
}
// Show different messages for server rejection vs missing credentials
if (error instanceof AuthFailSPError) {
this._snackService.open({
msg: T.F.SYNC.S.AUTH_TOKEN_REJECTED,
translateParams: { reason: error.message },
type: 'ERROR',
actionFn: () => this._openSyncCfgDialog(),
actionStr: T.F.SYNC.S.BTN_CONFIGURE,
});
} else {
this._snackService.open({
msg: T.F.SYNC.S.INCOMPLETE_CFG,
type: 'ERROR',
actionFn: () => this._openSyncCfgDialog(),
actionStr: T.F.SYNC.S.BTN_CONFIGURE,
});
}
return 'HANDLED_ERROR';
} else if (error instanceof EmptyRemoteBodySPError) {
// Remote file returned an empty body (e.g. Koofr WebDAV corrupted file).
// Force overwrite is safe: local data is intact, remote is empty.
this._providerManager.setSyncStatus('ERROR');
this._snackService.open({
msg: T.F.SYNC.S.ERROR_REMOTE_FILE_EMPTY,
type: 'ERROR',
config: { duration: 12000 },
actionFn: async () => this.forceUpload('EmptyRemoteBodySPError'),
actionStr: T.F.SYNC.S.BTN_FORCE_OVERWRITE,
});
return 'HANDLED_ERROR';
} else if (error instanceof JsonParseError) {
// Remote JSON is unparseable (e.g. truncated write, encoding issue).
// Force overwrite is safe: local data is intact, remote cannot be parsed.
// Issues: #5574, #4616.
this._providerManager.setSyncStatus('ERROR');
this._snackService.open({
msg: T.F.SYNC.S.ERROR_REMOTE_FILE_CORRUPTED,
type: 'ERROR',
config: { duration: 12000 },
actionFn: async () => this.forceUpload('JsonParseError'),
actionStr: T.F.SYNC.S.BTN_FORCE_OVERWRITE,
});
return 'HANDLED_ERROR';
} else if (error instanceof SyncDataCorruptedError) {
// Remote file format version is incompatible (could be older or newer than local).
// Do NOT offer force-upload: if remote is newer, overwriting would destroy newer data.
// Users should ensure all devices run the same app version.
this._providerManager.setSyncStatus('ERROR');
this._snackService.open({
msg: T.F.SYNC.S.ERROR_SYNC_VERSION_MISMATCH,
type: 'ERROR',
config: { duration: 12000 },
});
return 'HANDLED_ERROR';
} else if (error instanceof LegacySyncFormatDetectedError) {
// Remote has v16.x pfapi files (__meta_) but no sync-data.json. Usual cause:
// an old device still writing the legacy format, which would silently diverge
// from this client. Force-overwrite is offered as an escape hatch for the
// stale-__meta_ case (successful migration but old files never cleaned up);
// it drops any remaining v16 data in favor of the current local state.
// Issues: #5964, #6174.
this._providerManager.setSyncStatus('ERROR');
this._snackService.open({
msg: T.F.SYNC.S.LEGACY_FORMAT_DETECTED,
type: 'ERROR',
config: { duration: 20000 },
actionFn: async () => this.forceUpload('LegacySyncFormatDetectedError'),
actionStr: T.F.SYNC.S.BTN_FORCE_OVERWRITE,
});
return 'HANDLED_ERROR';
} else if (error instanceof HttpNotOkAPIError && error.response.status === 423) {
// HTTP 423 Locked: WebDAV server holds a file lock.
// Do NOT offer force overwrite — the PUT will also receive 423.
// The lock typically resolves on the next sync attempt.
this._providerManager.setSyncStatus('ERROR');
this._snackService.open({
msg: T.F.SYNC.S.ERROR_REMOTE_FILE_LOCKED,
type: 'ERROR',
});
return 'HANDLED_ERROR';
} else if (
error instanceof DecryptNoPasswordError ||
// Upload-side twin (GHSA-9544-hjjr-fg8h): encryption is enabled but the
// key is gone (dropped credentials) — same recovery as the download
// case: prompt for the password instead of syncing plaintext.
error instanceof EncryptNoPasswordError
) {
this._handleMissingPasswordDialog();
return 'HANDLED_ERROR';
} else if (error instanceof DecryptError) {
this._handleDecryptionError();
return 'HANDLED_ERROR';
} else if (error instanceof CanNotMigrateMajorDownError) {
// Log warning instead of alert - user can't do anything about version mismatch during import
SyncLog.warn(
'Remote model version newer than local - app update may be required',
);
return 'HANDLED_ERROR';
} else if (error instanceof SyncAlreadyInProgressError) {
// Silently ignore concurrent sync attempts (using proper error class)
SyncLog.log('Sync already in progress, skipping concurrent sync attempt');
return 'HANDLED_ERROR';
} else if (error instanceof LockAcquisitionTimeoutError) {
SyncLog.err(
`Lock acquisition timed out for "${error.lockName}" after ${error.timeoutMs}ms`,
);
// Self-healing like the network/timeout branches below: the lock is held
// by another in-flight op-log operation (compaction, a prior cycle, a
// queued write) and the next sync cycle retries once it frees. Since
// #8306 a lock timeout no longer wedges the write queue, so for automatic
// syncs (resume/focus/interval) the snack would just flash and vanish on
// its own — only surface it when the user explicitly asked to sync.
if (isUserTriggered) {
this._snackService.open({
msg: T.F.SYNC.S.LOCK_TIMEOUT_ERROR,
type: 'ERROR',
});
}
return 'HANDLED_ERROR';
} else if (error instanceof LocalDataConflictError) {
// File-based sync: Local data exists and remote snapshot would overwrite it
// Show conflict dialog to let user choose between local and remote data
return this._handleLocalDataConflict(error);
} else if (error instanceof WebCryptoNotAvailableError) {
// WebCrypto (crypto.subtle) is unavailable in insecure contexts
// (e.g., Android Capacitor serves from http://localhost)
this._providerManager.setSyncStatus('ERROR');
this._snackService.open({
msg: T.F.SYNC.S.WEB_CRYPTO_NOT_AVAILABLE,
type: 'ERROR',
config: { duration: 15000 },
});
return 'HANDLED_ERROR';
} else if (
error instanceof NetworkUnavailableSPError ||
isTransientNetworkError(error)
) {
// Transient + self-healing (the next sync cycle retries). SuperSync
// maps connectivity failures to NetworkUnavailableSPError; file-based
// providers (Dropbox/WebDAV) surface them as generic errors instead, so
// isTransientNetworkError() catches those too (e.g. UnknownHostException,
// "could not connect to the server") — provider-agnostic.
//
// Only surface a snack when the user explicitly asked to sync; for
// automatic syncs (notably the auto-sync fired on Android resume, before
// sockets/DNS recover from Doze) stay silent to avoid a snack that
// flashes and vanishes on its own.
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
if (isUserTriggered) {
this._snackService.open({
msg: T.F.SYNC.S.NETWORK_ERROR,
type: 'WARNING',
});
}
return 'HANDLED_ERROR';
} else if (this._isTimeoutError(error)) {
// Like transient network failures, a sync timeout is self-healing (the
// next cycle retries) and its "Please try again" message only makes
// sense for someone actively waiting. Structured native transport
// timeouts are handled by the network branch above so status is marked
// UNKNOWN_OR_CHANGED consistently.
if (isUserTriggered) {
this._snackService.open({
msg: T.F.SYNC.S.TIMEOUT_ERROR,
type: 'ERROR',
config: { duration: 12000 },
translateParams: {
suggestion:
'Large sync operations may take up to 90 seconds. Please try again.',
},
});
}
return 'HANDLED_ERROR';
} else if (this._isPermissionError(error)) {
this._snackService.open({
msg: this._getPermissionErrorMessage(),
type: 'ERROR',
config: { duration: 12000 },
});
return 'HANDLED_ERROR';
} else if (error instanceof UploadRevToMatchMismatchAPIError) {
// Another client uploaded between our download and upload — self-healing.
// The next sync cycle will download their ops first, then upload successfully.
// Do not show an error snackbar; just mark as UNKNOWN_OR_CHANGED so the
// next sync cycle triggers and resolves the state.
SyncLog.log(
'SyncWrapperService: Concurrent upload detected, will retry on next sync cycle',
);
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return 'HANDLED_ERROR';
} else if (error instanceof FileSyncTargetChangedError) {
// The file sync target (provider/account/folder) changed while this
// upload was in flight; the guarded write was abandoned before it could
// land the previous target's data on the new one — self-healing. Do not
// show an error snackbar; UNKNOWN_OR_CHANGED triggers the next sync,
// which re-reads and re-uploads against the current target from zero.
SyncLog.log(
'SyncWrapperService: Sync target changed mid-operation, will re-sync against the current target',
);
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return 'HANDLED_ERROR';
} else if (error instanceof OperationIntegrityError) {
// A decrypted op's unauthenticated metadata contradicted its authenticated
// payload, or a plaintext op arrived while encryption is mandatory
// (GHSA-8pxh-mgc7-gp3g). Fail closed with a calm, translated message so the
// generic handler below cannot surface the raw technical/GHSA string to the
// user. The technical details are already in the log.
SyncLog.err('SyncWrapperService: operation integrity check failed', {
name: error.name,
});
this._providerManager.setSyncStatus('ERROR');
this._snackService.open({
msg: T.F.SYNC.S.INTEGRITY_TAMPER_DETECTED,
type: 'ERROR',
config: { duration: 15000 },
});
return 'HANDLED_ERROR';
} else if (error instanceof ForceUploadPendingOpsError) {
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return 'HANDLED_ERROR';
} else if (error instanceof ForceUploadFailedError) {
this._providerManager.setSyncStatus('ERROR');
this._snackService.open({
msg: T.F.SYNC.S.FORCE_UPLOAD_FAILED,
type: 'ERROR',
});
return 'HANDLED_ERROR';
} else {
this._providerManager.setSyncStatus('ERROR');
const errStr = getSyncErrorStr(error);
// A lower-level recovery path may already have shown a sticky action
// (for example Undo after an interrupted remote-state rebuild). Snack
// rendering is debounced, so opening the generic error here would win
// the race and silently remove the only recovery action.
if (!this._snackService.hasPendingPersistentAction()) {
this._snackService.open({
// msg: T.F.SYNC.S.UNKNOWN_ERROR,
msg: errStr,
type: 'ERROR',
translateParams: {
err: errStr,
},
});
}
return 'HANDLED_ERROR';
}
}
}
private _handlePermanentUploadRejections(
uploadResults: readonly CompletedUploadOutcome[],
): boolean {
const rejectedResult = uploadResults.find((r) => r.permanentRejectionCount > 0);
if (!rejectedResult) {
return false;
}
const hasPayloadError = rejectedResult.rejectedOps.some(
(r) =>
r.error?.includes('Payload too complex') ||
r.error?.includes('Payload too large'),
);
if (hasPayloadError) {
SyncLog.err(
'SyncWrapperService: Upload rejected - payload too large/complex',
rejectedResult.rejectedOps,
);
this._providerManager.setSyncStatus('ERROR');
alertDialog(this._translateService.instant(T.F.SYNC.S.ERROR_PAYLOAD_TOO_LARGE));
return true;
}
SyncLog.err(
`SyncWrapperService: Upload had ${rejectedResult.permanentRejectionCount} permanent rejection(s), not marking as IN_SYNC`,
rejectedResult.rejectedOps,
);
this._providerManager.setSyncStatus('ERROR');
return true;
}
async forceUpload(triggerSource: ForceUploadTriggerSource = 'unknown'): Promise<void> {
if (!this._c(this._translateService.instant(T.F.SYNC.C.FORCE_UPLOAD))) {
return;
}
// Diagnostic: stamp the originating error/dialog so we can correlate
// "what stuck the user" with "what they recovered with" in shared logs.
SyncLog.log('SyncWrapperService: forceUpload called - uploading local state', {
triggerSource,
});
// Block parallel syncs during force upload to prevent them from trying to
// download/decrypt old data with a potentially different encryption key.
// This is critical when forceUpload is triggered after password change.
await this.runWithSyncBlocked(async () => {
// #8309: claim the sync cycle so this flow's setLastServerSeq bookkeeping
// and session-validation latch are isolated from the immediate-upload /
// WS-download side channels. runWithSyncBlocked
// has already drained any main sync and set isEncryptionOperationInProgress
// (which the side channels honour), so the only thing that could hold the
// guard here is a short-lived side channel; skip-and-let-the-user-retry
// rather than block.
if (!this._syncCycleGuard.tryBegin()) {
SyncLog.log('Force upload skipped: another sync cycle is in progress');
return;
}
try {
const rawProvider = this._providerManager.getActiveProvider();
const syncCapableProvider =
await this._wrappedProvider.getOperationSyncCapable(rawProvider);
if (!syncCapableProvider) {
SyncLog.warn(
'SyncWrapperService: Cannot force upload - provider not available',
);
return;
}
const forceUploadResult =
await this._opLogSyncService.forceUploadLocalState(syncCapableProvider);
this._providerManager.setSyncStatus(
forceUploadResult.hasUnresolvedOps ? 'UNKNOWN_OR_CHANGED' : 'IN_SYNC',
);
SyncLog.log('SyncWrapperService: Force upload complete');
} catch (error) {
if (error instanceof FileSyncTargetChangedError) {
// The file target switched during the force upload; the guarded write
// was abandoned before it could hit the new target. Self-healing like
// the main sync path — report UNKNOWN_OR_CHANGED (no error snack) so
// the next sync re-reads/re-uploads against the current target.
SyncLog.log(
'SyncWrapperService: target changed mid-force-upload, will re-sync against the current target',
);
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
} else if (error instanceof EncryptNoPasswordError) {
// GHSA-9544-hjjr-fg8h: a keyless-but-encryption-enabled provider makes
// force upload refuse to send plaintext. Route to the enter-password
// recovery dialog like the main sync path, not a dead-end error snack —
// otherwise "force overwrite" (offered as the lost-key recovery) loops.
this._handleMissingPasswordDialog();
} else {
SyncLog.err('SyncWrapperService: Force upload failed:', error);
this._providerManager.setSyncStatus('ERROR');
this._snackService.open({
msg: T.F.SYNC.S.FORCE_UPLOAD_FAILED,
type: 'ERROR',
});
}
} finally {
this._syncCycleGuard.end();
}
});
}
async configuredAuthForSyncProviderIfNecessary(
providerId: SyncProviderId,
force = false,
): Promise<{ wasConfigured: boolean }> {
const provider = await this._providerManager.getProviderById(providerId);
if (!provider) {
return { wasConfigured: false };
}
if (!provider.getAuthHelper) {
return { wasConfigured: false };
}
if (!force && (await provider.isReady())) {
SyncLog.warn('Provider already configured');
return { wasConfigured: false };
}
try {
const { authUrl, codeVerifier, verifyCodeChallenge } =
await provider.getAuthHelper();
if (authUrl && codeVerifier && verifyCodeChallenge) {
const authCode = await this._matDialog
.open(DialogGetAndEnterAuthCodeComponent, {
restoreFocus: true,
data: {
providerName: provider.id,
url: authUrl,
},
})
.afterClosed()
.toPromise();
if (authCode) {
const r = await verifyCodeChallenge(authCode);
// Preserve existing config (especially encryptKey) when updating auth
const existingConfig = await provider.privateCfg.load();
await this._providerManager.setProviderConfig(provider.id, {
...existingConfig,
...r,
});
// NOTE: exec sync afterward; promise not awaited
setTimeout(() => {
this.sync();
}, 1000);
return { wasConfigured: true };
} else {
return { wasConfigured: false };
}
}
} catch (error) {
SyncLog.err(`Failed to configure auth for provider ${providerId}:`, error);
const httpErr = error instanceof HttpNotOkAPIError ? error : null;
const httpStatus = httpErr?.response.status;
const isTokenExchangeError = httpStatus === 400;
const isAuthServiceUnavailable =
httpStatus !== undefined && httpStatus >= 500 && httpStatus < 600;
// Dropbox recommends retaining this response ID for support investigations.
// https://developers.dropbox.com/error-handling-guide#logging
const providerRequestId =
providerId === SyncProviderId.Dropbox
? httpErr?.response.headers.get('X-Dropbox-Request-Id')
: null;
// A OneDrive token-exchange 400 is almost always a misconfigured
// Microsoft Entra app registration (typically "Allow public client
// flows" disabled), not a mistyped/expired code — the authorize step
// already succeeded. Point the user at the registration fix and surface
// the Azure error detail (AADSTSxxxxx, carried on the UI-only `.detail`)
// so it is self-diagnosable.
let msg: string;
let translateParams: { [key: string]: string } | undefined;
if (isTokenExchangeError && providerId === SyncProviderId.OneDrive) {
msg = T.F.SYNC.S.ONEDRIVE_AUTH_FAILED;
translateParams = { error: httpErr?.detail || getErrorTxt(error) };
} else if (isTokenExchangeError) {
msg = T.F.SYNC.S.INVALID_AUTH_CODE;
} else if (isAuthServiceUnavailable) {
translateParams = { status: String(httpStatus) };
if (providerRequestId) {
msg = T.F.SYNC.S.AUTH_SERVICE_UNAVAILABLE_WITH_REFERENCE;
translateParams.reference = escapeHtml(providerRequestId);
} else {
msg = T.F.SYNC.S.AUTH_SERVICE_UNAVAILABLE;
}
} else {
msg = T.F.SYNC.S.AUTH_SETUP_FAILED;
}
this._snackService.open({
msg,
translateParams,
type: 'ERROR',
config: { duration: 0 },
});
return { wasConfigured: false };
}
return { wasConfigured: false };
}
private async _openSyncCfgDialog(): Promise<void> {
const { DialogSyncCfgComponent } =
await import('./dialog-sync-cfg/dialog-sync-cfg.component');
this._matDialog.open(DialogSyncCfgComponent);
}
/**
* Handles missing encryption password when receiving encrypted data.
* Opens a simple dialog to prompt for the password, then re-syncs.
*/
private _handleMissingPasswordDialog(): void {
// Suppress dialog if user previously cancelled (so they can navigate to settings)
if (this._suppressEncryptionDialogs) {
this._providerManager.setSyncStatus('ERROR');
return;
}
// Prevent multiple password dialogs from opening simultaneously
if (this._passwordDialog) {
return;
}
// Set ERROR status so sync button shows error icon
this._providerManager.setSyncStatus('ERROR');
// Open dialog for password entry
this._passwordDialog = this._matDialog.open(DialogEnterEncryptionPasswordComponent, {
width: '450px',
disableClose: true,
});
firstValueFrom(this._passwordDialog.afterClosed())
.then((result) => {
this._passwordDialog = undefined;
if (result?.password) {
// Password was entered and saved — clear suppression and re-sync
this._suppressEncryptionDialogs = false;
this.sync();
} else if (result?.forceOverwrite) {
// Force overwrite succeeded; reflect synced status
this._suppressEncryptionDialogs = false;
this._providerManager.setSyncStatus('IN_SYNC');
} else {
// User cancelled — suppress future dialogs so they can navigate to settings
this._suppressEncryptionDialogs = true;
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
}
})
.catch((err) => {
this._passwordDialog = undefined;
SyncLog.err('Error handling missing password dialog result:', err);
});
}
private _handleDecryptionError(): void {
// Suppress dialog if user previously cancelled (so they can navigate to settings)
if (this._suppressEncryptionDialogs) {
this._providerManager.setSyncStatus('ERROR');
return;
}
// Prevent multiple password dialogs from opening simultaneously
if (this._passwordDialog) {
return;
}
// Set ERROR status so sync button shows error icon
this._providerManager.setSyncStatus('ERROR');
// Show snackbar (consistent with other error handlers)
this._snackService.open({
msg: T.F.SYNC.S.DECRYPTION_FAILED,
type: 'ERROR',
config: { duration: 10000 }, // Longer duration for critical errors
});
// Open dialog for password correction
this._passwordDialog = this._matDialog.open(DialogHandleDecryptErrorComponent, {
disableClose: true,
});
firstValueFrom(this._passwordDialog.afterClosed())
.then((result) => {
this._passwordDialog = undefined;
if (result?.isReSync) {
this._suppressEncryptionDialogs = false;
this.sync();
} else if (result?.isForceUpload) {
this._suppressEncryptionDialogs = false;
this.forceUpload('DecryptError');
} else {
// User cancelled — suppress future dialogs so they can navigate to settings
this._suppressEncryptionDialogs = true;
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
}
})
.catch((err) => {
this._passwordDialog = undefined;
SyncLog.err('Error handling decrypt error dialog result:', err);
});
}
/**
* Handles LocalDataConflictError by showing a conflict resolution dialog.
* This occurs when sync detects local data that would be overwritten by remote data.
*
* User can choose:
* - USE_LOCAL: Upload local data, overwriting remote (uses forceUploadLocalState)
* - USE_REMOTE: Download remote data, discarding local (uses forceDownloadRemoteState)
*/
private async _handleLocalDataConflict(
error: LocalDataConflictError,
): Promise<SyncStatus | 'HANDLED_ERROR'> {
// Signal that we're waiting for user input (prevents sync timeout)
const stopWaiting = this._userInputWaitState.startWaiting('local-data-conflict');
try {
// Build ConflictData for the dialog
const vcEntry = await this._opLogStore.getVectorClockEntry();
const localClock = vcEntry?.clock;
const localLastUpdate = vcEntry?.lastUpdate || Date.now();
const conflictData: ConflictData = {
reason: ConflictReason.NoLastSync,
remote: {
lastUpdate: error.remoteLastModified ?? null,
lastUpdateAction: 'Remote data',
revMap: {},
crossModelVersion: 1,
mainModelData: error.remoteSnapshotState,
isFullData: true,
vectorClock: error.remoteVectorClock,
},
local: {
lastUpdate: localLastUpdate,
lastUpdateAction: `${error.unsyncedCount} local changes pending`,
revMap: {},
crossModelVersion: 1,
// Op-log (NoLastSync) conflicts do not carry a last-synced timestamp, so
// this is always null here; the dialog renders it as "Never"/"-".
lastSyncedUpdate: null,
metaRev: null,
vectorClock: localClock,
lastSyncedVectorClock: error.lastSyncedVectorClock ?? null,
},
localUnsyncedOpsCount: error.unsyncedCount,
};
SyncLog.log(
`SyncWrapperService: Showing conflict dialog for ${error.unsyncedCount} local changes vs remote snapshot`,
);
const resolution = await firstValueFrom(this._openConflictDialog$(conflictData));
// Get sync provider for the resolution operation
const rawProvider = this._providerManager.getActiveProvider();
const syncCapableProvider =
await this._wrappedProvider.getOperationSyncCapable(rawProvider);
if (!syncCapableProvider) {
SyncLog.err(
'SyncWrapperService: Cannot resolve conflict - provider not available',
);
return 'HANDLED_ERROR';
}
if (resolution === 'USE_LOCAL') {
// User chose to keep local data and upload it to remote
SyncLog.log(
'SyncWrapperService: User chose USE_LOCAL - uploading local state to overwrite remote',
);
const forceUploadResult =
await this._opLogSyncService.forceUploadLocalState(syncCapableProvider);
if (forceUploadResult.hasUnresolvedOps) {
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return 'HANDLED_ERROR';
}
this._providerManager.setSyncStatus('IN_SYNC');
return SyncStatus.InSync;
} else if (resolution === 'USE_REMOTE') {
// User chose to discard local data and download remote.
// Reset latch — read after forceDownloadRemoteState returns. (#7330)
SyncLog.log(
'SyncWrapperService: User chose USE_REMOTE - downloading remote state, discarding local',
);
this._sessionValidation.reset();
await this._opLogSyncService.forceDownloadRemoteState(syncCapableProvider);
if (this._sessionValidation.hasFailed()) {
SyncLog.err(
'SyncWrapperService: USE_REMOTE applied but post-sync validation failed; reporting ERROR',
);
this._providerManager.setSyncStatus('ERROR');
return 'HANDLED_ERROR';
}
this._providerManager.setSyncStatus('IN_SYNC');
return SyncStatus.InSync;
} else {
// User cancelled the dialog
SyncLog.log('SyncWrapperService: User cancelled first sync conflict dialog');
this._snackService.open({
msg: T.F.SYNC.S.LOCAL_DATA_REPLACE_CANCELLED,
});
return 'HANDLED_ERROR';
}
} catch (resolutionError) {
if (resolutionError instanceof FileSyncTargetChangedError) {
// Target switched during USE_LOCAL force upload; the guarded write was
// abandoned. Self-heal silently (UNKNOWN_OR_CHANGED) like the main sync
// path instead of a dead-end error snack.
SyncLog.log(
'SyncWrapperService: target changed mid-conflict-resolution, will re-sync against the current target',
);
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return 'HANDLED_ERROR';
}
// GHSA-9544-hjjr-fg8h: USE_LOCAL force-uploads, which refuses to send
// plaintext when the key is missing. Route to the enter-password recovery
// dialog instead of a dead-end error snack (mirrors the main sync path).
if (resolutionError instanceof EncryptNoPasswordError) {
this._handleMissingPasswordDialog();
return 'HANDLED_ERROR';
}
// Error during conflict resolution (forceUpload or forceDownload failed)
SyncLog.err(
'SyncWrapperService: Error during conflict resolution:',
resolutionError,
);
this._providerManager.setSyncStatus('ERROR');
this._snackService.open({
msg:
resolutionError instanceof ForceUploadFailedError
? T.F.SYNC.S.FORCE_UPLOAD_FAILED
: getSyncErrorStr(resolutionError),
type: 'ERROR',
});
return 'HANDLED_ERROR';
} finally {
stopWaiting();
}
}
private _c(str: string): boolean {
return confirmDialog(this._translateService.instant(str));
}
private _isPermissionError(error: unknown): boolean {
const errStr = String(error);
return /EROFS|EACCES|EPERM|read-only file system|permission denied/i.test(errStr);
}
private _isTimeoutError(error: unknown): boolean {
const errStr = String(error).toLowerCase();
return (
errStr.includes('timeout') ||
errStr.includes('504') ||
errStr.includes('gateway timeout')
);
}
private _getPermissionErrorMessage(): string {
if (IS_ELECTRON && window.ea?.isFlatpak?.()) {
return T.F.SYNC.S.ERROR_PERMISSION_FLATPAK;
}
if (IS_ELECTRON && window.ea?.isSnap?.()) {
return T.F.SYNC.S.ERROR_PERMISSION_SNAP;
}
return T.F.SYNC.S.ERROR_PERMISSION;
}
private lastConflictDialog?: MatDialogRef<
DialogSyncConflictComponent,
DialogConflictResolutionResult
>;
/**
* Reference to any open password-related dialog (enter password or decrypt error).
* Used to prevent multiple simultaneous password dialogs from opening.
* Uses Record<string, unknown> because dialog components are dynamically imported.
*/
private _passwordDialog?: MatDialogRef<any, Record<string, unknown>>;
/**
* Reference to the encryption-required dialog to prevent multiple opens.
* Uses Record<string, unknown> because dialog component is dynamically imported.
*/
private _encryptionRequiredDialog?: MatDialogRef<any, Record<string, unknown>>;
/**
* Synchronous guard to prevent TOCTOU race in _promptSuperSyncEncryptionIfNeeded.
* The async import() between checking _encryptionRequiredDialog and opening the dialog
* creates a window where concurrent calls can both pass the guard.
*/
private _isOpeningEncryptionDialog = false;
/**
* One-shot flag: the legacy post-sync setup modal only fires for the fresh-setup
* sync (the sync triggered right after the config dialog enables SuperSync).
* Established/returning unencrypted accounts are nudged by the calm, dismissible
* SuperSyncEncryptionMigrationBannerService instead, so the per-sync modal must
* NOT fire for them — otherwise both would prompt at startup. Set by the config
* dialog via markPromptEncryptionAfterSetupSync(); consumed on the first
* post-sync prompt evaluation for SuperSync (see _promptSuperSyncEncryptionIfNeeded).
*/
private _shouldPromptEncryptionAfterSetupSync = false;
/**
* Called by the sync config dialog immediately after (re)enabling SuperSync from
* a disabled state, so the setup encryption modal fires once for that setup sync.
*/
markPromptEncryptionAfterSetupSync(): void {
this._shouldPromptEncryptionAfterSetupSync = true;
}
/**
* After a successful sync, checks if SuperSync is active without encryption.
* If so, opens the encryption dialog. Data has already been synced, so no data loss.
*/
private async _promptSuperSyncEncryptionIfNeeded(): Promise<void> {
SyncLog.log(
'_promptSuperSyncEncryptionIfNeeded called, _isOpeningEncryptionDialog=',
this._isOpeningEncryptionDialog,
', _encryptionRequiredDialog=',
!!this._encryptionRequiredDialog,
', openDialogs=',
this._matDialog.openDialogs.length,
);
const providerId = await firstValueFrom(this.syncProviderId$);
if (providerId !== SyncProviderId.SuperSync) {
return;
}
const provider = this._providerManager.getActiveProvider();
if (!provider) {
return;
}
// Established/returning unencrypted accounts are owned by the calm migration
// banner (SuperSyncEncryptionMigrationBannerService); only the fresh-setup sync
// that armed the flag fires this dead-end modal, so the two never both prompt.
// Consume the one-shot flag HERE (not at dialog-open) so it can't leak to a
// later, unrelated sync via one of the early-returns below. A failed setup sync
// returns HANDLED_ERROR and never reaches this method, so the arming survives
// and retries on the next successful sync. Tradeoff: if the modal-open is later
// blocked (another dialog open / TOCTOU guard below), the flag is already spent
// and this modal won't retry — acceptable because the migration banner catches
// the still-unencrypted account on the next app start (seq is now > 0).
// TODO(#8670): once the mandatory-encryption upload guard lands, retire this
// modal + flag entirely and let the banner own all cohorts.
if (!this._shouldPromptEncryptionAfterSetupSync) {
SyncLog.log(
'Skipping legacy setup encryption modal — migration banner owns established nudge',
);
return;
}
this._shouldPromptEncryptionAfterSetupSync = false;
const cfg = (await provider.privateCfg.load()) as
| { isEncryptionEnabled?: boolean; encryptKey?: string }
| undefined;
if (cfg?.isEncryptionEnabled && cfg?.encryptKey) {
SyncLog.log('SuperSync encryption already enabled, skipping');
return;
}
SyncLog.log(
'SuperSync encryption not enabled — prompting user, openDialogs=',
this._matDialog.openDialogs.length,
', _isOpeningEncryptionDialog=',
this._isOpeningEncryptionDialog,
', _encryptionRequiredDialog=',
!!this._encryptionRequiredDialog,
);
// If our own encryption prompt is already open or being opened, there is
// nothing to do — and we must not wait on it below.
if (this._encryptionRequiredDialog || this._isOpeningEncryptionDialog) {
SyncLog.log('Encryption prompt already open/opening — skipping');
return;
}
// A transiently-open dialog — typically the sync-config dialog still playing
// its close animation right after first-time setup — must only DEFER this
// prompt, never drop it. With the E2EE-mandatory upload guard (GHSA-9v8x) the
// initial sync finishes almost instantly (upload is skipped until a key
// exists), so it can now beat the config dialog's close animation; a one-shot
// skip here then leaves SuperSync enabled with no encryption configured and no
// prompt shown (#8670 regression). Wait (bounded) for open dialogs to clear,
// then re-check state before prompting. A competing dialog that is still open
// after the wait (e.g. an enter-password flow the user is interacting with) is
// left alone — the next sync re-runs this check.
if (this._matDialog.openDialogs.length > 0) {
SyncLog.log('Dialog open — deferring encryption prompt until it closes');
const waitStart = Date.now();
while (
this._matDialog.openDialogs.length > 0 &&
Date.now() - waitStart < ENCRYPTION_PROMPT_DIALOG_WAIT_MS
) {
await new Promise((resolve) =>
setTimeout(resolve, ENCRYPTION_PROMPT_DIALOG_POLL_MS),
);
}
if (this._matDialog.openDialogs.length > 0) {
SyncLog.log('Dialog still open after wait — skipping encryption prompt');
return;
}
// Provider/encryption state can change during the wait: the closing dialog
// may switch providers, disable SuperSync, or configure encryption itself
// (e.g. an enter-password flow). Re-validate the active provider and its
// config before prompting — otherwise we could open the disableClose setup
// dialog for a provider that is no longer active, trapping the user.
const providerIdAfterWait = await firstValueFrom(this.syncProviderId$);
if (providerIdAfterWait !== SyncProviderId.SuperSync) {
SyncLog.log('Provider changed while waiting — skipping encryption prompt');
return;
}
const providerAfterWait = this._providerManager.getActiveProvider();
if (!providerAfterWait) {
return;
}
const cfgAfterWait = (await providerAfterWait.privateCfg.load()) as
| { isEncryptionEnabled?: boolean; encryptKey?: string }
| undefined;
if (cfgAfterWait?.isEncryptionEnabled && cfgAfterWait?.encryptKey) {
SyncLog.log('Encryption enabled while waiting — skipping prompt');
return;
}
}
if (!this._encryptionRequiredDialog && !this._isOpeningEncryptionDialog) {
this._isOpeningEncryptionDialog = true;
SyncLog.log('Opening encryption dialog (guard passed)');
try {
const { DialogEnableEncryptionComponent } =
await import('./dialog-enable-encryption/dialog-enable-encryption.component');
// Double-check after async import: another call might have opened a dialog
if (this._encryptionRequiredDialog || this._matDialog.openDialogs.length > 0) {
SyncLog.log('Dialog appeared during import — aborting');
return;
}
this._encryptionRequiredDialog = this._matDialog.open(
DialogEnableEncryptionComponent,
{
disableClose: true,
data: { providerType: 'supersync', initialSetup: true },
},
);
this._encryptionRequiredDialog.afterClosed().subscribe((result) => {
this._encryptionRequiredDialog = undefined;
if (result?.success) {
this.sync();
}
});
} finally {
this._isOpeningEncryptionDialog = false;
}
} else {
SyncLog.log(
'Skipping encryption dialog — guard blocked: _encryptionRequiredDialog=',
!!this._encryptionRequiredDialog,
', _isOpeningEncryptionDialog=',
this._isOpeningEncryptionDialog,
);
}
}
private _openConflictDialog$(
conflictData: ConflictData,
): Observable<DialogConflictResolutionResult | undefined> {
if (this.lastConflictDialog) {
this.lastConflictDialog.close();
}
this.lastConflictDialog = this._matDialog.open(DialogSyncConflictComponent, {
restoreFocus: true,
disableClose: true,
data: conflictData,
});
// disableClose blocks ESC/backdrop, but a programmatic close (iOS app
// lifecycle, navigation, or re-entry calling close()) emits `undefined`.
// Forward it as-is so _handleLocalDataConflict treats it as cancellation;
// filtering it would leave firstValueFrom() to throw EmptyError (issue #7339).
return this.lastConflictDialog.afterClosed();
}
/**
* Syncs the current vector clock from SUP_OPS to pf.META_MODEL.
* Called before legacy sync providers start syncing.
* This ensures the legacy sync provider sees the latest vector clock.
*/
private async _syncVectorClockToPfapi(): Promise<void> {
const vcEntry = await this._opLogStore.getVectorClockEntry();
if (vcEntry) {
SyncLog.log('[SyncWrapper] Syncing vector clock to pf.META_MODEL', {
clockSize: Object.keys(vcEntry.clock).length,
lastUpdate: vcEntry.lastUpdate,
});
const existing = await this._legacyPfDb.loadMetaModel();
await this._legacyPfDb.saveMetaModel({
...existing,
vectorClock: vcEntry.clock,
lastUpdate: vcEntry.lastUpdate,
});
} else {
SyncLog.log('[SyncWrapper] No vector clock in SUP_OPS, skipping sync');
}
}
}