mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* 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.
1735 lines
73 KiB
TypeScript
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');
|
|
}
|
|
}
|
|
}
|