| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { CLOUD_SYNC_KEYS, type CloudSyncKey } from './sync-keys'; |
| import { isDesktopRuntime } from '@/services/runtime'; |
| import { getClerkToken } from '@/services/clerk'; |
| import { FEEDS } from '@/config/feeds'; |
| import { |
| applyMigrationChain, |
| buildMigrations, |
| mergeCloudWithLocalDirty, |
| parsePersistedDirtyKeys, |
| settledDirtyKeys, |
| } from './cloud-prefs-migrations'; |
| import { |
| isTemporaryCloudPrefsStatus, |
| parseRetryAfterSeconds, |
| rearmTemporaryCloudPrefsRetry, |
| } from './cloud-prefs-retry'; |
| import { applyObservableCloudPrefsFlushSuccess } from './cloud-prefs-flush'; |
| import { SerializedAsyncQueue } from './serialized-async-queue'; |
| import { TimeoutError, withTimeout } from './with-timeout'; |
| import { setTrustedHtml, trustedHtml } from '@/utils/dom-utils'; |
|
|
| export { isTemporaryCloudPrefsStatus, parseRetryAfterSeconds } from './cloud-prefs-retry'; |
|
|
|
|
| const ENABLED = import.meta.env.VITE_CLOUD_PREFS_ENABLED === 'true'; |
| export const CLOUD_PREFS_APPLIED_EVENT = 'wm:cloud-prefs-applied'; |
|
|
| export interface CloudPrefsAppliedDetail { |
| keys: CloudSyncKey[]; |
| } |
|
|
| |
| const KEY_SYNC_VERSION = 'wm-cloud-sync-version'; |
| const KEY_LAST_SYNC_AT = 'wm-last-sync-at'; |
| const KEY_SYNC_STATE = 'wm-cloud-sync-state'; |
| const KEY_LAST_SIGNED_IN_AS = 'wm-last-signed-in-as'; |
| const KEY_DIRTY_KEYS = 'wm-cloud-prefs-dirty-keys'; |
| |
| |
| |
| |
| |
| |
| |
| const KEY_LOCAL_SCHEMA_VERSION = 'wm-cloud-prefs-local-schema-version'; |
|
|
| const CURRENT_PREFS_SCHEMA_VERSION = 2; |
| const CLOUD_PREFS_REQUEST_TIMEOUT_MS = 15_000; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const MIGRATIONS = buildMigrations(FEEDS); |
|
|
| type SyncState = 'synced' | 'pending' | 'syncing' | 'conflict' | 'offline' | 'signed-out' | 'error'; |
|
|
| let _debounceTimer: ReturnType<typeof setTimeout> | null = null; |
| let _currentVariant = 'full'; |
| let _installed = false; |
| let _suppressPatch = false; |
| let _cachedToken: string | null = null; |
|
|
| |
| |
| |
| |
| |
| const _dirtyKeys = new Set<CloudSyncKey>(); |
| let _dirtyKeysUserId: string | null = null; |
|
|
| function persistDirtyKeys(): void { |
| try { |
| if (_dirtyKeys.size === 0) { |
| Storage.prototype.removeItem.call(localStorage, KEY_DIRTY_KEYS); |
| return; |
| } |
| if (!_dirtyKeysUserId) return; |
| Storage.prototype.setItem.call(localStorage, KEY_DIRTY_KEYS, JSON.stringify({ |
| userId: _dirtyKeysUserId, |
| keys: [..._dirtyKeys], |
| })); |
| } catch { |
| |
| } |
| } |
|
|
| function hydrateDirtyKeysFromStorage(userId: string): void { |
| try { |
| _dirtyKeys.clear(); |
| _dirtyKeysUserId = userId; |
| const raw = localStorage.getItem(KEY_DIRTY_KEYS); |
| for (const key of parsePersistedDirtyKeys(raw, CLOUD_SYNC_KEYS, userId)) { |
| _dirtyKeys.add(key as CloudSyncKey); |
| } |
| if (raw !== null && _dirtyKeys.size === 0) persistDirtyKeys(); |
| } catch { |
| |
| } |
| } |
|
|
| function markDirtyKey(key: CloudSyncKey): void { |
| _dirtyKeys.add(key); |
| persistDirtyKeys(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function clearSettledDirtyKeys(postedBlob: Record<string, string>): void { |
| let changed = false; |
| for (const key of settledDirtyKeys(postedBlob, buildCloudBlob(), _dirtyKeys)) { |
| changed = _dirtyKeys.delete(key as CloudSyncKey) || changed; |
| } |
| if (changed) persistDirtyKeys(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| let _retryTimer: ReturnType<typeof setTimeout> | null = null; |
| let _authGeneration = 0; |
| const _syncOperations = new SerializedAsyncQueue(); |
| let _activeUploadPromise: Promise<void> | null = null; |
| let _queuedUploadVariant = 'full'; |
|
|
| function clearRetryTimer(): void { |
| if (_retryTimer !== null) { |
| clearTimeout(_retryTimer); |
| _retryTimer = null; |
| } |
| } |
|
|
| |
|
|
| function isEnabled(): boolean { |
| return ENABLED && !isDesktopRuntime(); |
| } |
|
|
| export function isCloudSyncEnabled(): boolean { |
| return isEnabled(); |
| } |
|
|
| |
|
|
| function getSyncVersion(): number { |
| return parseInt(localStorage.getItem(KEY_SYNC_VERSION) ?? '0', 10) || 0; |
| } |
|
|
| function setSyncVersion(v: number): void { |
| |
| Storage.prototype.setItem.call(localStorage, KEY_SYNC_VERSION, String(v)); |
| } |
|
|
| function setState(s: SyncState): void { |
| Storage.prototype.setItem.call(localStorage, KEY_SYNC_STATE, s); |
| } |
|
|
| |
|
|
| function buildCloudBlob(): Record<string, string> { |
| const blob: Record<string, string> = {}; |
| for (const key of CLOUD_SYNC_KEYS) { |
| const val = localStorage.getItem(key); |
| if (val !== null) blob[key] = val; |
| } |
| return blob; |
| } |
|
|
| function dispatchCloudPrefsApplied(keys: CloudSyncKey[]): void { |
| if (keys.length === 0 || typeof window === 'undefined') return; |
| window.dispatchEvent(new CustomEvent<CloudPrefsAppliedDetail>(CLOUD_PREFS_APPLIED_EVENT, { |
| detail: { keys }, |
| })); |
| } |
|
|
| function applyCloudBlob(data: Record<string, unknown>): void { |
| const changedKeys: CloudSyncKey[] = []; |
| _suppressPatch = true; |
| try { |
| for (const key of CLOUD_SYNC_KEYS) { |
| const val = data[key]; |
| if (typeof val === 'string') { |
| if (localStorage.getItem(key) !== val) changedKeys.push(key); |
| localStorage.setItem(key, val); |
| } else if (!(key in data)) { |
| if (localStorage.getItem(key) !== null) changedKeys.push(key); |
| localStorage.removeItem(key); |
| } |
| } |
| } finally { |
| _suppressPatch = false; |
| } |
| dispatchCloudPrefsApplied(changedKeys); |
| } |
|
|
| function applyMigrations( |
| data: Record<string, unknown>, |
| fromVersion: number, |
| ): Record<string, unknown> { |
| return applyMigrationChain(data, fromVersion, CURRENT_PREFS_SCHEMA_VERSION, MIGRATIONS); |
| } |
|
|
| function getLocalSchemaVersion(): number { |
| const raw = localStorage.getItem(KEY_LOCAL_SCHEMA_VERSION); |
| if (raw === null) return 1; |
| const v = parseInt(raw, 10); |
| return Number.isFinite(v) && v > 0 ? v : 1; |
| } |
|
|
| function setLocalSchemaVersion(v: number): void { |
| Storage.prototype.setItem.call(localStorage, KEY_LOCAL_SCHEMA_VERSION, String(v)); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function migrateLocalBlobIfNeeded(): Record<string, string> { |
| const localSchema = getLocalSchemaVersion(); |
| const blob = buildCloudBlob(); |
| if (localSchema >= CURRENT_PREFS_SCHEMA_VERSION) return blob; |
| const migrated = applyMigrations(blob, localSchema) as Record<string, string>; |
| if (migrated !== blob) applyCloudBlob(migrated); |
| setLocalSchemaVersion(CURRENT_PREFS_SCHEMA_VERSION); |
| return migrated; |
| } |
|
|
| |
|
|
| function showUndoToast(prevBlobJson: string): void { |
| document.querySelector('.wm-sync-restore-toast')?.remove(); |
|
|
| const toast = document.createElement('div'); |
| toast.className = 'wm-sync-restore-toast update-toast'; |
| setTrustedHtml(toast, trustedHtml(` |
| <div class="update-toast-body"> |
| <div class="update-toast-title">Settings restored</div> |
| <div class="update-toast-detail">Your preferences were loaded from the cloud.</div> |
| </div> |
| <button class="update-toast-action" data-action="undo">Undo</button> |
| <button class="update-toast-dismiss" data-action="dismiss" aria-label="Dismiss">\u00d7</button> |
| `, "legacy direct innerHTML migration")); |
|
|
| const autoTimer = setTimeout(() => toast.remove(), 5000); |
|
|
| toast.addEventListener('click', (e) => { |
| const action = (e.target as HTMLElement).closest('[data-action]')?.getAttribute('data-action'); |
| if (action === 'undo') { |
| const prev = JSON.parse(prevBlobJson) as Record<string, string>; |
| const restoredKeys: CloudSyncKey[] = []; |
| _suppressPatch = true; |
| try { |
| for (const [k, v] of Object.entries(prev)) { |
| if (!CLOUD_SYNC_KEYS.includes(k as CloudSyncKey)) continue; |
| const key = k as CloudSyncKey; |
| if (localStorage.getItem(key) !== v) restoredKeys.push(key); |
| localStorage.setItem(key, v); |
| } |
| } finally { |
| _suppressPatch = false; |
| } |
| dispatchCloudPrefsApplied(restoredKeys); |
| toast.remove(); |
| clearTimeout(autoTimer); |
| } else if (action === 'dismiss') { |
| toast.remove(); |
| clearTimeout(autoTimer); |
| } |
| }); |
|
|
| document.body.appendChild(toast); |
| } |
|
|
| |
|
|
| interface CloudPrefs { |
| data: Record<string, unknown>; |
| schemaVersion: number; |
| syncVersion: number; |
| } |
|
|
| |
| |
| |
| |
| |
| export class ServiceUnavailableError extends Error { |
| retryAfterSec: number; |
| status: number; |
| constructor(retryAfterSec: number, status = 503) { |
| super(`service temporarily unavailable (${status}; retry after ${retryAfterSec}s)`); |
| this.name = 'ServiceUnavailableError'; |
| this.retryAfterSec = retryAfterSec; |
| this.status = status; |
| } |
| } |
|
|
| function asTemporaryCloudPrefsError(error: unknown): never { |
| const name = (error as { name?: unknown } | null)?.name; |
| if (error instanceof TimeoutError || name === 'TimeoutError' || name === 'AbortError') { |
| throw new ServiceUnavailableError(parseRetryAfterSeconds(new Headers()), 504); |
| } |
| throw error; |
| } |
|
|
| async function getCloudPrefsToken(): Promise<string | null> { |
| try { |
| return await withTimeout( |
| getClerkToken(), |
| CLOUD_PREFS_REQUEST_TIMEOUT_MS, |
| 'cloud prefs token', |
| ); |
| } catch (error) { |
| return asTemporaryCloudPrefsError(error); |
| } |
| } |
|
|
| async function fetchCloudPrefs(token: string, variant: string): Promise<CloudPrefs | null> { |
| let res: Response; |
| try { |
| res = await fetch(`/api/user-prefs?variant=${encodeURIComponent(variant)}`, { |
| headers: { Authorization: `Bearer ${token}` }, |
| signal: AbortSignal.timeout(CLOUD_PREFS_REQUEST_TIMEOUT_MS), |
| }); |
| } catch (error) { |
| return asTemporaryCloudPrefsError(error); |
| } |
| if (res.status === 401) return null; |
| if (isTemporaryCloudPrefsStatus(res.status)) throw new ServiceUnavailableError(parseRetryAfterSeconds(res.headers), res.status); |
| if (!res.ok) throw new Error(`fetch prefs: ${res.status}`); |
| return (await res.json()) as CloudPrefs | null; |
| } |
|
|
| async function postCloudPrefs( |
| token: string, |
| variant: string, |
| data: Record<string, string>, |
| expectedSyncVersion: number, |
| ): Promise<{ syncVersion: number } | { conflict: true; actualSyncVersion?: number }> { |
| let res: Response; |
| try { |
| res = await fetch('/api/user-prefs', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| Authorization: `Bearer ${token}`, |
| }, |
| body: JSON.stringify({ variant, data, expectedSyncVersion, schemaVersion: CURRENT_PREFS_SCHEMA_VERSION }), |
| signal: AbortSignal.timeout(CLOUD_PREFS_REQUEST_TIMEOUT_MS), |
| }); |
| } catch (error) { |
| return asTemporaryCloudPrefsError(error); |
| } |
| if (res.status === 409) { |
| |
| |
| |
| |
| const body = await res.json().catch(() => ({} as Record<string, unknown>)); |
| const actualSyncVersion = typeof body.actualSyncVersion === 'number' ? body.actualSyncVersion : undefined; |
| return { conflict: true, actualSyncVersion }; |
| } |
| if (isTemporaryCloudPrefsStatus(res.status)) throw new ServiceUnavailableError(parseRetryAfterSeconds(res.headers), res.status); |
| if (!res.ok) throw new Error(`post prefs: ${res.status}`); |
| return (await res.json()) as { syncVersion: number }; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function resolveConflictWithMerge(token: string, variant: string, callerGeneration: number): Promise<boolean> { |
| const fresh = await fetchCloudPrefs(token, variant); |
| if (_authGeneration !== callerGeneration) return false; |
| if (!fresh) { |
| setState('error'); |
| return false; |
| } |
| const migratedCloud = applyMigrations(fresh.data, fresh.schemaVersion ?? 1); |
| const merged = mergeCloudWithLocalDirty(migratedCloud, buildCloudBlob(), _dirtyKeys); |
| applyCloudBlob(merged); |
| setSyncVersion(fresh.syncVersion); |
| setLocalSchemaVersion(CURRENT_PREFS_SCHEMA_VERSION); |
| const retry = await postCloudPrefs(token, variant, merged, fresh.syncVersion); |
| if (_authGeneration !== callerGeneration) return false; |
| if ('conflict' in retry) { |
| setState('conflict'); |
| return false; |
| } |
| |
| |
| |
| |
| setSyncVersion(retry.syncVersion); |
| clearSettledDirtyKeys(merged); |
| Storage.prototype.setItem.call(localStorage, KEY_LAST_SYNC_AT, String(Date.now())); |
| setState('synced'); |
| return true; |
| } |
|
|
| export function onSignIn(userId: string, variant: string): Promise<void> { |
| if (!isEnabled()) return Promise.resolve(); |
|
|
| |
| |
| |
| |
| clearRetryTimer(); |
| _authGeneration += 1; |
| const myGeneration = _authGeneration; |
| |
| |
| |
| |
| hydrateDirtyKeysFromStorage(userId); |
|
|
| return _syncOperations.run(async () => { |
| if (_authGeneration !== myGeneration) return; |
|
|
| _currentVariant = variant; |
| setState('syncing'); |
|
|
| try { |
| const token = await getCloudPrefsToken(); |
| if (_authGeneration !== myGeneration) return; |
| if (!token) { setState('error'); return; } |
| _cachedToken = token; |
|
|
| const cloud = await fetchCloudPrefs(token, variant); |
| if (_authGeneration !== myGeneration) return; |
|
|
| if (cloud && cloud.syncVersion > getSyncVersion()) { |
| const isFirstEverSync = getSyncVersion() === 0; |
| const prevBlobJson = isFirstEverSync ? JSON.stringify(buildCloudBlob()) : null; |
|
|
| const migrated = applyMigrations(cloud.data, cloud.schemaVersion ?? 1); |
| const migrationChanged = (cloud.schemaVersion ?? 1) < CURRENT_PREFS_SCHEMA_VERSION; |
| |
| |
| |
| const hasDirty = _dirtyKeys.size > 0; |
| const toApply = hasDirty |
| ? mergeCloudWithLocalDirty(migrated, buildCloudBlob(), _dirtyKeys) |
| : migrated; |
| applyCloudBlob(toApply); |
| setSyncVersion(cloud.syncVersion); |
| |
| |
| |
| setLocalSchemaVersion(CURRENT_PREFS_SCHEMA_VERSION); |
| |
| |
| |
| if (migrationChanged || hasDirty) schedulePrefUpload(variant); |
| Storage.prototype.setItem.call(localStorage, KEY_LAST_SYNC_AT, String(Date.now())); |
|
|
| if (isFirstEverSync && prevBlobJson && Object.keys(cloud.data).length > 0) { |
| showUndoToast(prevBlobJson); |
| } |
|
|
| setState('synced'); |
| } else { |
| |
| |
| |
| |
| |
| |
| const blob = migrateLocalBlobIfNeeded(); |
| const result = await postCloudPrefs(token, variant, blob, getSyncVersion()); |
| if (_authGeneration !== myGeneration) return; |
|
|
| if ('conflict' in result) { |
| |
| |
| |
| await resolveConflictWithMerge(token, variant, myGeneration); |
| } else { |
| setSyncVersion(result.syncVersion); |
| clearSettledDirtyKeys(blob); |
| Storage.prototype.setItem.call(localStorage, KEY_LAST_SYNC_AT, String(Date.now())); |
| setState('synced'); |
| } |
| } |
|
|
| if (_authGeneration === myGeneration) { |
| Storage.prototype.setItem.call(localStorage, KEY_LAST_SIGNED_IN_AS, userId); |
| } |
| } catch (err) { |
| if (_authGeneration !== myGeneration) return; |
| if (err instanceof ServiceUnavailableError) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| console.warn(`[cloud-prefs] onSignIn ${err.status}; retrying in ${err.retryAfterSec}s`); |
| setState('pending'); |
| clearRetryTimer(); |
| _retryTimer = setTimeout(() => { |
| _retryTimer = null; |
| if (_authGeneration !== myGeneration) return; |
| void onSignIn(userId, variant); |
| }, err.retryAfterSec * 1000); |
| return; |
| } |
| console.warn('[cloud-prefs] onSignIn failed:', err); |
| setState(!navigator.onLine || (err instanceof TypeError && err.message.includes('fetch')) ? 'offline' : 'error'); |
| } |
| }); |
| } |
|
|
| export function onSignOut(): void { |
| if (!isEnabled()) return; |
|
|
| const preservePersistedDirtyKeys = _syncOperations.busy && _dirtyKeys.size > 0; |
| if (_debounceTimer !== null && _cachedToken) { |
| |
| clearTimeout(_debounceTimer); |
| _debounceTimer = null; |
| |
| |
| |
| if (!_syncOperations.busy) { |
| const blob = buildCloudBlob(); |
| const token = _cachedToken; |
| void _syncOperations.run(async () => { |
| await fetch('/api/user-prefs', { |
| method: 'POST', |
| keepalive: true, |
| headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, |
| body: JSON.stringify({ variant: _currentVariant, data: blob, expectedSyncVersion: getSyncVersion(), schemaVersion: CURRENT_PREFS_SCHEMA_VERSION }), |
| signal: AbortSignal.timeout(CLOUD_PREFS_REQUEST_TIMEOUT_MS), |
| }); |
| }).catch(() => { }); |
| } |
| } else if (_debounceTimer !== null) { |
| clearTimeout(_debounceTimer); |
| _debounceTimer = null; |
| } |
| |
| |
| |
| |
| |
| clearRetryTimer(); |
| _authGeneration += 1; |
| _cachedToken = null; |
| |
| |
| |
| |
| _dirtyKeys.clear(); |
| if (!preservePersistedDirtyKeys) persistDirtyKeys(); |
| _dirtyKeysUserId = null; |
|
|
| |
| localStorage.removeItem(KEY_SYNC_VERSION); |
| localStorage.removeItem(KEY_LAST_SYNC_AT); |
| setState('signed-out'); |
| } |
|
|
| |
| |
| |
| |
| |
| async function performUploadNow(variant: string): Promise<'completed' | 'retry-deferred' | 'stopped'> { |
| |
| |
| |
| |
| |
| |
| const myGeneration = _authGeneration; |
|
|
| try { |
| const token = await getCloudPrefsToken(); |
| if (_authGeneration !== myGeneration) return 'stopped'; |
| if (!token) return 'stopped'; |
| _cachedToken = token; |
|
|
| setState('syncing'); |
|
|
| const postedBlob = migrateLocalBlobIfNeeded(); |
| const result = await postCloudPrefs(token, variant, postedBlob, getSyncVersion()); |
| if (_authGeneration !== myGeneration) return 'stopped'; |
|
|
| if ('conflict' in result) { |
| setState('conflict'); |
| |
| |
| |
| |
| if (!await resolveConflictWithMerge(token, variant, myGeneration)) return 'stopped'; |
| } else { |
| |
| |
| |
| |
| |
| |
| setSyncVersion(result.syncVersion); |
| clearSettledDirtyKeys(postedBlob); |
| Storage.prototype.setItem.call(localStorage, KEY_LAST_SYNC_AT, String(Date.now())); |
| setState('synced'); |
| } |
| } catch (err) { |
| if (_authGeneration !== myGeneration) return 'stopped'; |
| if (err instanceof ServiceUnavailableError) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| console.warn(`[cloud-prefs] uploadNow ${err.status}; retrying in ${err.retryAfterSec}s`); |
| setState('pending'); |
| clearRetryTimer(); |
| _retryTimer = setTimeout(() => { |
| _retryTimer = null; |
| if (_authGeneration !== myGeneration) return; |
| void uploadNow(variant); |
| }, err.retryAfterSec * 1000); |
| return 'retry-deferred'; |
| } |
| console.warn('[cloud-prefs] uploadNow failed:', err); |
| setState(!navigator.onLine || (err instanceof TypeError && err.message.includes('fetch')) ? 'offline' : 'error'); |
| return 'stopped'; |
| } |
| return 'completed'; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| function uploadNow(variant: string): Promise<void> { |
| _queuedUploadVariant = variant; |
| if (_activeUploadPromise !== null) { |
| setState('pending'); |
| return _activeUploadPromise; |
| } |
|
|
| const requestedGeneration = _authGeneration; |
| const queuedUpload = _syncOperations.run(async () => { |
| if (_authGeneration !== requestedGeneration) return; |
|
|
| while (_authGeneration === requestedGeneration) { |
| const outcome = await performUploadNow(_queuedUploadVariant); |
| if (outcome !== 'completed' || _dirtyKeys.size === 0) return; |
| } |
| }); |
| const activeUpload = queuedUpload.finally(() => { |
| if (_activeUploadPromise === activeUpload) _activeUploadPromise = null; |
| }); |
| _activeUploadPromise = activeUpload; |
| return activeUpload; |
| } |
|
|
| function schedulePrefUpload(variant: string): void { |
| setState('pending'); |
| if (_debounceTimer !== null) clearTimeout(_debounceTimer); |
| _debounceTimer = setTimeout(async () => { |
| _debounceTimer = null; |
| await uploadNow(variant); |
| }, 5000); |
| } |
|
|
| export function onPrefChange(variant: string): void { |
| if (!isEnabled()) return; |
| _currentVariant = variant; |
| schedulePrefUpload(variant); |
| } |
|
|
| export async function syncNow(): Promise<void> { |
| if (!isEnabled()) return; |
| if (_debounceTimer !== null) { |
| clearTimeout(_debounceTimer); |
| _debounceTimer = null; |
| } |
| await uploadNow(_currentVariant); |
| } |
|
|
| export function getSyncState(): SyncState { |
| return (localStorage.getItem(KEY_SYNC_STATE) as SyncState) || 'signed-out'; |
| } |
|
|
| export function getLastSyncAt(): number { |
| return parseInt(localStorage.getItem(KEY_LAST_SYNC_AT) ?? '0', 10) || 0; |
| } |
|
|
| |
|
|
| export function install(variant: string): void { |
| if (!isEnabled() || _installed) return; |
| _installed = true; |
| _currentVariant = variant; |
|
|
| |
| |
| const originalSetItem = Storage.prototype.setItem; |
| Storage.prototype.setItem = function setItem(key: string, value: string) { |
| originalSetItem.call(this, key, value); |
| if (this === localStorage && !_suppressPatch && CLOUD_SYNC_KEYS.includes(key as CloudSyncKey)) { |
| markDirtyKey(key as CloudSyncKey); |
| schedulePrefUpload(_currentVariant); |
| } |
| }; |
|
|
| const originalRemoveItem = Storage.prototype.removeItem; |
| Storage.prototype.removeItem = function removeItem(key: string) { |
| originalRemoveItem.call(this, key); |
| if (this === localStorage && !_suppressPatch && CLOUD_SYNC_KEYS.includes(key as CloudSyncKey)) { |
| markDirtyKey(key as CloudSyncKey); |
| schedulePrefUpload(_currentVariant); |
| } |
| }; |
|
|
| |
| window.addEventListener('storage', (e) => { |
| if (e.key === KEY_SYNC_VERSION && e.newValue !== null) { |
| const newV = parseInt(e.newValue, 10); |
| if (newV > getSyncVersion()) { |
| if (_debounceTimer !== null) { |
| clearTimeout(_debounceTimer); |
| _debounceTimer = null; |
| setState('synced'); |
| } |
| Storage.prototype.setItem.call(localStorage, KEY_SYNC_VERSION, e.newValue); |
| } |
| } |
| }); |
|
|
| |
| |
| const flushOnUnload = (): void => { |
| if (_debounceTimer === null || !_cachedToken) return; |
| clearTimeout(_debounceTimer); |
| _debounceTimer = null; |
|
|
| |
| |
| |
| if (_syncOperations.busy) { |
| void uploadNow(_currentVariant); |
| return; |
| } |
|
|
| |
| |
| |
| const blob = migrateLocalBlobIfNeeded(); |
| const myGeneration = _authGeneration; |
| const payload = JSON.stringify({ variant: _currentVariant, data: blob, expectedSyncVersion: getSyncVersion(), schemaVersion: CURRENT_PREFS_SCHEMA_VERSION }); |
| void _syncOperations.run(async () => { |
| await fetch('/api/user-prefs', { |
| method: 'POST', |
| keepalive: true, |
| headers: { |
| 'Content-Type': 'application/json', |
| Authorization: `Bearer ${_cachedToken}`, |
| }, |
| body: payload, |
| signal: AbortSignal.timeout(CLOUD_PREFS_REQUEST_TIMEOUT_MS), |
| }).then(async (res) => { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if (!res.ok) { |
| rearmTemporaryCloudPrefsRetry({ |
| status: res.status, |
| headers: res.headers, |
| myGeneration, |
| getAuthGeneration: () => _authGeneration, |
| setPending: () => setState('pending'), |
| clearRetryTimer, |
| setRetryTimer: (timer) => { _retryTimer = timer; }, |
| uploadNow: () => uploadNow(_currentVariant), |
| }); |
| return; |
| } |
| const body = (await res.json().catch(() => null)) as { syncVersion?: number } | null; |
| applyObservableCloudPrefsFlushSuccess({ |
| syncVersion: body?.syncVersion, |
| myGeneration, |
| getAuthGeneration: () => _authGeneration, |
| getSyncVersion, |
| setSyncVersion, |
| clearSettledDirtyKeys: () => clearSettledDirtyKeys(blob), |
| setLastSyncAt: (timestampMs) => { |
| Storage.prototype.setItem.call(localStorage, KEY_LAST_SYNC_AT, String(timestampMs)); |
| }, |
| |
| |
| |
| isIdle: () => _debounceTimer === null && _activeUploadPromise === null, |
| setSynced: () => setState('synced'), |
| }); |
| }); |
| }).catch(() => { }); |
| }; |
|
|
| document.addEventListener('visibilitychange', () => { |
| if (document.visibilityState === 'hidden') flushOnUnload(); |
| }); |
| window.addEventListener('pagehide', flushOnUnload); |
| } |
|
|